diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 28ecf269b2..87ef49e0df 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -2077,6 +2077,9 @@ "process/2026-07-06-generated-config-catalog.i18n.yaml": "sha256:48e64ccfc7622559a9b964e2741ad82a9fa37243a0d5c472f0482d9c748d7739", "process/2026-07-06-generated-config-catalog.md": "sha256:c8654a4e084bfbf4f7d8eb78239dc1d5d62e1384b2aed309fa2526a4d6de0f04", "process/2026-07-06-generated-config-catalog.zh.md": "sha256:5ab8d964f12e0cf9a04251e988f0b2a114d7ae8ea7ed337c00f625213cb1dc94", + "process/2026-07-06-node-engine-floor.i18n.yaml": "sha256:65d73bf7a6c2fb7609ff3a8f18a435e83bb488e005365d065d184942958bfb35", + "process/2026-07-06-node-engine-floor.md": "sha256:b62c8e8dd008b0ab7874b5d57c20c629ffb71e4b4a62922afaf6a1039d25898e", + "process/2026-07-06-node-engine-floor.zh.md": "sha256:3cd685e59f913ef700b3e2ed3b941c2266bb2de706810cbe570f4832a26a5034", "process/2026-07-06-parallel-github-ci-gates.i18n.yaml": "sha256:0f6ece268d9a51bc20cb8eb929f26d8838761603a64eb08dc24521198f10da36", "process/2026-07-06-parallel-github-ci-gates.md": "sha256:6249bd7396ae7f2d0dc671879ce21cefab33a47ace6ef17a25a70e8650b815af", "process/2026-07-06-parallel-github-ci-gates.zh.md": "sha256:cf7edb9bcf97ab1d4e452330c0df0b127a664509ec3f11597ace3eabeb663a5b", diff --git a/.agents/notes/archived/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/archived/process/2026-07-06-node-engine-floor.i18n.yaml new file mode 100644 index 0000000000..8aaf400607 --- /dev/null +++ b/.agents/notes/archived/process/2026-07-06-node-engine-floor.i18n.yaml @@ -0,0 +1,3 @@ +# Frozen bilingual archive consistency record: Git blob hashes after archive metadata insertion. +2026-07-06-node-engine-floor.md: 4e399b2a71bb4a76a28bc4b643a761ce65c55865 +2026-07-06-node-engine-floor.zh.md: abd4913688cc4b3af02fa2f0b06d4b8206652d13 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/archived/process/2026-07-06-node-engine-floor.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-06-node-engine-floor.md rename to .agents/notes/archived/process/2026-07-06-node-engine-floor.md index e2fe7661f4..4e399b2a71 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md +++ b/.agents/notes/archived/process/2026-07-06-node-engine-floor.md @@ -1,6 +1,7 @@ # Agent Note: Raise the Node LTS engine floor to 22.19 Status: implemented +Archived: 2026-10-15 English | [中文](2026-07-06-node-engine-floor.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/archived/process/2026-07-06-node-engine-floor.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md rename to .agents/notes/archived/process/2026-07-06-node-engine-floor.zh.md index e7d41e8128..abd4913688 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/archived/process/2026-07-06-node-engine-floor.zh.md @@ -1,6 +1,7 @@ # Agent Note: 将 Node LTS 引擎下限提升至 22.19 Status: implemented +Archived: 2026-10-15 [English](2026-07-06-node-engine-floor.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index 490d5c5d24..1d7c7a29bb 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -18,8 +18,8 @@ en: b432f80a424c3e49 zh: faa26925fdd98387 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/provider-availability-and-selection: - en: dff78c46432311a1 - zh: 58d5db4c3c85b886 + en: 4e4ec068c2a22383 + zh: b7030d0f00bfdb74 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/search-request-and-result-schema: en: ec3703b7e7c6b23d zh: 325755e6e90092da diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 8c6c088ea5..135447cb15 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -60,6 +60,7 @@ flowchart LR exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web toolWeb -->|ctx.tools.register| webSearch["tool: web_search"] @@ -151,6 +152,8 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`. +Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Web search settings switch relies on this to move both capabilities to the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md) at once. + `ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider. ## Search request and result schema diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index 4921c4a464..736b50ebb3 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -60,6 +60,7 @@ flowchart LR exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web toolWeb -->|ctx.tools.register| webSearch["tool: web_search"] @@ -151,6 +152,8 @@ interface WebRuntime { 运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。 +每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即对下一次操作选择另一个提供方。网页搜索设置页的开关借此把两种能力同时切换到 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)。 + `ctx.web.search()` 和 `ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 或 `WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。 ## 搜索请求与结果 schema 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 aaa8fdbedc..49e0e11ef4 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 @@ -21,11 +21,11 @@ en: 985e0eb63f3d1fd6 zh: 337cc44fadc6227e /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts: - en: 921085cb9d894688 - zh: 12d99fe153d36738 + en: 9d885e8d58bc8bbb + zh: 41a9779fbdea8a1a /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development: - en: 02e6b36f9f92c83a - zh: edd8b2db7a787483 + en: b2a740a1ab7a5002 + zh: a48be6ca6fc65912 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/naming-lineage: en: 31a5aecb8bf828bb zh: 2dffb6fd2e0f8194 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 60228735f5..8349e7cf31 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 @@ -48,13 +48,13 @@ The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed de ### 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 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. +[`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 --compress Brotli` 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, 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`. +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 24.9 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, 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. 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 272c0e80c9..f28f3da059 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 @@ -48,13 +48,13 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.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 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 +[`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 --compress Brotli` → 将 `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 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`。 +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 24.9 或更高版本上运行 `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、轻量资源元数据与 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 包。 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 163ebfc9fa..a91608f093 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -27,8 +27,8 @@ en: 37e2fa1fe5cb2b95 zh: 80e513db87709cb4 /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/hot-reload-one-driver-plugin-self-watched-bundles: - en: f8b591d4c41ef492 - zh: bc70f1cd540b7753 + en: c1fdcb3e6f074a7a + zh: b08c7b1ff25acb09 /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/package-ownership: en: 94ca0d09e6fd1e3a zh: d5949a2bdec795ba diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 9cbdbc2961..493eb8c506 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -94,7 +94,7 @@ On the browser side, the transport delegates code replacement to the same module Bootstrap replacement is rejected before invalidation or teardown: the module system retains its initial exports, so remounting its old code would reset consumers without applying the requested revision. The page reports the required reload and preserves the bootstrap fiber. Every non-bootstrap plugin shares the replacement semantics; an `immediately` row reloads exactly like a lazy one. Dependency cascade costs zero client code: a fiber's activation epoch strings its service providers' uids, so replacing a foundational provider such as connection re-loads every dependent through cordis itself — correct, if heavy. -Reload creates a fresh fiber and component state; it does not preserve React state inside the replaced plugin. Static assembly libraries and the shell require a rebuilt page. Failed imports or activation remain diagnosable and retryable without rolling back unrelated plugins. Self-reload closes the old SSE channel and opens a new one; its full snapshot repairs missed graph changes. Boot, graph updates and rebuilt frames share the same queue, so a code replacement cannot overlap initial module arrival. +Reload creates a fresh fiber and component state; it does not preserve React state inside the replaced plugin. Static assembly libraries and the shell require a rebuilt page; when the Host itself serves another release, the page holds plugin replacement and asks whether to reload ([Host release change prompt](../feature/2026-10-14-host-release-change-refresh-prompt.md)). Failed imports or activation remain diagnosable and retryable without rolling back unrelated plugins. Self-reload closes the old SSE channel and opens a new one; its full snapshot repairs missed graph changes. Boot, graph updates and rebuilt frames share the same queue, so a code replacement cannot overlap initial module arrival. ## Package ownership diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 93e59c2c76..f7bf1452d6 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -94,7 +94,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完 Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始导出,重新挂载旧代码会重置消费者,却无法应用请求的 revision。页面会报告需要刷新,并保留 bootstrap fiber。所有非 bootstrap 插件都共享同一套替换语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。 -重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。 +重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面;Host 本身换成另一个 release 时,页面会暂停插件替换并询问是否刷新([Host release 变化提示](../feature/2026-10-14-host-release-change-refresh-prompt.zh.md))。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。 ## 包归属 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index 7bd5c73590..29bc08ae86 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml @@ -6,8 +6,8 @@ en: 55846ac57e07eeda zh: 3de03730d4b28863 /agent-note-dsh-source-launch-through-the-tsx-esm-hook/problem: - en: 68385f7e47d3ac1f - zh: 285ca290832e2939 + en: d13aa56d01c1181d + zh: 837427ed6963c39d /agent-note-dsh-source-launch-through-the-tsx-esm-hook/decision: en: 12adf8ed56b4d5c3 zh: fbbb9add935730c0 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index 700577cb9b..7949dc568b 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -8,7 +8,7 @@ English | [中文](2026-07-29-dsh-source-launch-tsx-esm.zh.md) ## Problem -The [archived native source-launch decision](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently. +The [archived native source-launch decision](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The supported engines range includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently. Startup latency also mattered: the off-thread `module.register()` hooks worker serialized every resolution across threads (~440ms of `makeSyncRequest` wait during TUI boot), and the full tsx default (`--import tsx`) pays ~0.4s in its CJS hook's resolution amplification. diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index 4d322d3dcf..cc306b4ddb 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -8,7 +8,7 @@ Status: implemented ## 问题 -[已归档的原生源码启动决策](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。 +[已归档的原生源码启动决策](../../archived/architecture/2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。受支持的 engines 范围包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。 启动延迟同样是问题:off-thread 的 `module.register()` 钩子工作线程把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整的 tsx 默认形态(`--import tsx`)会因其 CJS 钩子放大解析开销而多花约 0.4s。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml index 0d30f02d4f..18f65a56f9 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml @@ -6,14 +6,14 @@ en: b19652a0e1cc1826 zh: 4d3511e4f0db5b9d /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/problem: - en: 70e5f3ed33010c98 - zh: 5437d943953f5a40 + en: 6379f0a2522f4c46 + zh: c45ffeb5089be274 /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/decision: - en: efe23161a54f70c5 - zh: e24ee097918b2724 + en: 92526818ff4870c2 + zh: 8c54ff3f4941d5ad /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/alternatives-considered: - en: df44eb15557b95fe - zh: b757e53da4c0a99f + en: df6c078f239a11f5 + zh: 914da26836aa373b /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/consequences: en: 6a4acbab915f1bd1 zh: 2c8ffd62ba74ff96 diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md index e588ff751f..7d9b909678 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md @@ -10,7 +10,7 @@ Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`. Every other tool The repository had briefly had an answer and lost it without noticing. PR #971 set `NODE_USE_ENV_PROXY=1` in `bin/dsh`; eleven days later the “cleanup: remove managed source installer” change deleted that launcher wholesale, taking the flag with it. What survived was one sentence in `apps/cli/reference/README.md` telling the reader to set a variable that nothing consumed any more. -That sentence could not have worked anyway, for three measured reasons. `NODE_USE_ENV_PROXY` samples the environment at process start, while `loadLayeredEnv()` merges the `.env` layers afterwards, so a proxy declared in `$DSH_HOME/.env` is invisible to it. It reaches Node 24.0+ and, on the 22 line, only 22.21+ — while `engines` admits `^22.19.0`, where the variable does not exist and setting it warns about nothing. And it does not reach `web-fetch-http` at all: that provider passes its own `dispatcher` to `fetch`, and an explicit dispatcher overrides the global one whatever the flag says. +That sentence could not have worked anyway, for three measured reasons. `NODE_USE_ENV_PROXY` samples the environment at process start, while `loadLayeredEnv()` merges the `.env` layers afterwards, so a proxy declared in `$DSH_HOME/.env` is invisible to it. It reaches Node 24.0+ and, on the 22 line, only 22.21+ — the runtime floor at adoption was Node 22.19, where the variable did not exist and setting it warned about nothing. And it does not reach `web-fetch-http` at all: that provider passes its own `dispatcher` to `fetch`, and an explicit dispatcher overrides the global one whatever the flag says. ## Decision @@ -48,7 +48,7 @@ This accepts a documented seam. Such a context matches bypass entries by Node's **SDK transports need independent verification.** The OTLP exporter selects `http-exporter-transport` on Node, which posts through `node:http` and bypasses the global fetch dispatcher; a `globalThis.fetch` reference in its browser implementation does not prove Node routing. The [E2B removal](../simplification/2026-09-11-remove-e2b-providers.md) retires a second SDK transport integration without changing this requirement. -**Telemetry stays direct on purpose.** Routing it needs one of two things, and both cost more than the channel is worth. An `http.Agent` reads the environment through `proxyEnv`, which arrived in Node 22.21 and 24.5 — inside the engines range, so 22.19, 22.20, and 24.0–24.4 would stay direct regardless, and the proxy package would have to keep a `createNodeHttpAgent` export for a path that works on some runtimes. Replacing the transport with the SDK's `fetch` delegate covers every runtime, but that delegate has no compression, and the shipped `base` bundle enables gzip: a realistic OTLP batch measures 6.4x smaller with it. An attempt that refused `exporter.compression` instead broke every test that boots the shipped bundle, and one that gzipped at the serializer worked but put transport code in a telemetry plugin to keep it working. +**Telemetry stays direct on purpose.** Routing it needs one of two things, and both cost more than the channel is worth. An `http.Agent` reads the environment through `proxyEnv`, which arrived in Node 22.21 and 24.5 — above the runtime floor at adoption, so Node 22.19, 22.20, and 24.0–24.4 stayed direct regardless, and the proxy package would have to keep a `createNodeHttpAgent` export for a path that worked on some runtimes. The Node 24.9 floor removes this API gap; routing still needs independent transport integration and must preserve compression. Replacing the transport with the SDK's `fetch` delegate covers every runtime, but that delegate has no compression, and the shipped `base` bundle enables gzip: a realistic OTLP batch measures 6.4x smaller with it. An attempt that refused `exporter.compression` instead broke every test that boots the shipped bundle, and one that gzipped at the serializer worked but put transport code in a telemetry plugin to keep it working. Weighed against that, telemetry is the one outbound channel whose loss costs the user nothing: no tool, no model request, and no session depends on it, and an export that cannot connect is already dropped silently. A user behind a mandatory proxy is left exactly where they were before this change rather than regressed. `egress.spec.ts` now asserts the exclusion — an SDK upgrade that moved the exporter onto `fetch` would start routing telemetry through a proxy silently, and that case is what makes it visible. @@ -58,7 +58,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the ## Alternatives considered -**Document `NODE_USE_ENV_PROXY=1` and stop.** Rejected on three measurements, above: invisible to `$DSH_HOME/.env`, absent on the lowest supported Node, and bypassed by `web-fetch-http` regardless. It is also what the repository already claimed to do. +**Document `NODE_USE_ENV_PROXY=1` and stop.** Rejected at adoption on the three measurements above: invisible to `$DSH_HOME/.env`, absent on the Node 22.19 floor, and bypassed by `web-fetch-http` regardless. The environment-snapshot and explicit-dispatcher limitations remain on the current runtime floor. **Thread a policy value to every call site.** DeepSeek-Reasonix does this across 98 sites, buying a per-provider opt-out. Rejected: that opt-out exists for a need this harness does not have, and nine sites changed by hand means the tenth is forgotten — Pi's changelog records OAuth and Bedrock as two separate after-the-fact fixes of exactly that kind. The isolation argument for it is real, and is answered instead by handling worker threads explicitly and by proving disposal restores the previous dispatcher. diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md index 57faea2561..f24e811af7 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md @@ -10,7 +10,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 仓库曾短暂拥有过答案,又在无人察觉时弄丢了。PR #971 在 `bin/dsh` 里设置了 `NODE_USE_ENV_PROXY=1`;十一天后的“cleanup: remove managed source installer”改动整体删除了那个启动器,把该标志一并带走。留下的只有 `apps/cli/reference/README.md` 里的一句话,让读者去设置一个已经无人消费的变量。 -即便照做,那句话也不可能生效,原因有三条且都经过实测。`NODE_USE_ENV_PROXY` 在进程启动时对环境取快照,而 `loadLayeredEnv()` 是在之后才合并 `.env` 层,因此写在 `$DSH_HOME/.env` 中的代理对它不可见。它只覆盖 Node 24.0+,在 22 线上只覆盖 22.21+——而 `engines` 允许 `^22.19.0`,那里根本没有这个变量,设置了也不会有任何警告。它也完全触及不到 `web-fetch-http`:该提供方向 `fetch` 传入自己的 `dispatcher`,而显式 dispatcher 无论标志如何都会覆盖全局的那个。 +即便照做,那句话也不可能生效,原因有三条且都经过实测。`NODE_USE_ENV_PROXY` 在进程启动时对环境取快照,而 `loadLayeredEnv()` 是在之后才合并 `.env` 层,因此写在 `$DSH_HOME/.env` 中的代理对它不可见。它只覆盖 Node 24.0+,在 22 线上只覆盖 22.21+——采用此决策时的运行时下限为 Node 22.19,那里根本没有这个变量,设置了也不会有任何警告。它也完全触及不到 `web-fetch-http`:该提供方向 `fetch` 传入自己的 `dispatcher`,而显式 dispatcher 无论标志如何都会覆盖全局的那个。 ## Decision @@ -48,7 +48,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 **SDK 传输需要独立验证。** OTLP 导出器在 Node 上选择 `http-exporter-transport`,通过 `node:http` 投递并绕过全局 fetch dispatcher;其浏览器实现中的 `globalThis.fetch` 引用不能证明 Node 路由。[E2B 移除决策](../simplification/2026-09-11-remove-e2b-providers.zh.md)撤下另一项 SDK 传输集成,但不改变这一要求。 -**遥测的直连是有意为之。** 要让它走代理只有两条路,代价都超过这条通道本身的价值。`http.Agent` 通过 `proxyEnv` 读取环境,而该选项自 Node 22.21 与 24.5 才有——落在 engines 范围之内,因此 22.19、22.20 与 24.0–24.4 无论如何仍是直连,而代理包还得为一条只在部分运行时生效的路径保留 `createNodeHttpAgent` 导出。改用 SDK 的 `fetch` delegate 替换传输可以覆盖所有运行时,但该 delegate 没有压缩能力,而随附的 `base` bundle 启用了 gzip:实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。曾有一版转而在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败;另一版在 serializer 处 gzip 确实能跑通,但代价是把传输层代码塞进了遥测插件。 +**遥测的直连是有意为之。** 要让它走代理只有两条路,代价都超过这条通道本身的价值。`http.Agent` 通过 `proxyEnv` 读取环境,而该选项自 Node 22.21 与 24.5 才有——高于采用此决策时的运行时下限,因此 Node 22.19、22.20 与 24.0–24.4 无论如何仍是直连,而代理包还得为一条只在当时部分运行时生效的路径保留 `createNodeHttpAgent` 导出。Node 24.9 下限消除了这一 API 缺口;代理路由仍需要独立集成传输,并保留压缩。改用 SDK 的 `fetch` delegate 替换传输可以覆盖所有运行时,但该 delegate 没有压缩能力,而随附的 `base` bundle 启用了 gzip:实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。曾有一版转而在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败;另一版在 serializer 处 gzip 确实能跑通,但代价是把传输层代码塞进了遥测插件。 与之相比,遥测是唯一一条丢失了对用户毫无代价的出网通道:没有任何工具、模型请求或会话依赖它,而连不上的导出本就被静默丢弃。处在强制代理后的用户,只是停留在本次改动之前的状态,而不是被弄坏。`egress.spec.ts` 现在断言这一排除——若某次 SDK 升级把导出器挪到 `fetch` 上,遥测就会开始静默走代理,而该用例正是让这件事暴露出来的东西。 @@ -58,7 +58,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 ## Alternatives considered -**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 基于上文三条实测被否决:对 `$DSH_HOME/.env` 不可见、在最低支持的 Node 上不存在、且无论如何被 `web-fetch-http` 绕过。而这恰恰是仓库此前声称的做法。 +**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 采用此决策时基于上文三条实测被否决:对 `$DSH_HOME/.env` 不可见、在 Node 22.19 下限上不存在、且无论如何被 `web-fetch-http` 绕过。环境快照和显式 dispatcher 的限制在当前运行时下限上仍然存在。 **把策略值传递到每一个调用点。** DeepSeek-Reasonix 在 98 处这样做,换来每提供方的 opt-out。被否决:该能力服务于本 Harness 并不具备的需求,而手工改九处意味着第十处会被遗忘——Pi 的变更日志正记录了 OAuth 与 Bedrock 两次事后补漏。它关于隔离性的论点确实成立,本方案改为显式处理 worker 线程、并以「dispose 后还原前一个 dispatcher」的断言来回应。 diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml index aa017c7b03..5f05a79299 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml @@ -33,5 +33,5 @@ en: 939cedceed200cb2 zh: c7bb02d0da400c8d /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/consequences: - en: 1b9e31b4e773fac5 - zh: c140ce07fc64bd55 + en: 5a0120a6a0d48912 + zh: 649e7bc118bda1a2 diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md index 83612801d2..f510ed74d3 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md +++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md @@ -113,5 +113,5 @@ Tests need no API key and make no model calls. - With HMR, supported package operations publish the updated private dependencies and remove unused mappings after plugins stop. Without HMR, a pending startup-bundle disablement defers later publications until restart, preserving running plugins' mappings. - In the supported thread, package.json configuration reads use the changed fields; JSON modules retain their own reload behavior. The separate module, loader-thread, and Worker limitations above still apply. -- Package configuration below expired directories is parsed in JavaScript and must keep the native reader's field and error semantics; tests compare it with the native reader on Node 22, 24, and 26. +- Package configuration below expired directories is parsed in JavaScript and must keep the native reader's field and error semantics; tests compare it with the native reader on Node 24 and 26. - The implementation depends on several Node internal interfaces, and these tests must run again for Node upgrades. diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md index 6f19b5fa4a..c42befc34c 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md @@ -113,5 +113,5 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调 - 有 HMR 时,支持的包操作发布更新后的私有依赖,并在插件停止后移除不再需要的映射。没有 HMR 时,待生效的启动 bundle 停用会将后续发布延迟到重启,保留运行中插件的映射。 - 在支持的线程内,package.json 配置读取使用更新后的字段;JSON 模块保留自己的重载行为。上述模块、loader 线程及 Worker 的独立限制仍然适用。 -- 包配置读取改由 JS 解析 package.json,需要与原生读取保持字段和错误语义一致;测试在 Node 22、24、26 上与原生读取逐项比较。 +- 包配置读取改由 JS 解析 package.json,需要与原生读取保持字段和错误语义一致;测试在 Node 24、26 上与原生读取逐项比较。 - 依赖多个 Node internal 接口,Node 升级时需要重跑这些测试。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.i18n.yaml new file mode 100644 index 0000000000..5071ac4a0f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-09-composer-command-chip.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-composer-command-chip.md +/agent-note-claimed-commands-as-atomic-composer-chips: + en: 66d4f149698760c8 + zh: de219f394dfd4a4c +/agent-note-claimed-commands-as-atomic-composer-chips/problem: + en: 831c97e0de131639 + zh: ea35b4b179ff33f0 +/agent-note-claimed-commands-as-atomic-composer-chips/decision: + en: 6ac53f6cd1307897 + zh: 2f1dbe762b52f18e +/agent-note-claimed-commands-as-atomic-composer-chips/alternatives-considered: + en: 9c694a21237d7772 + zh: b35ba7bf8b73b679 +/agent-note-claimed-commands-as-atomic-composer-chips/consequences: + en: 089365d6a4be0ebb + zh: b30384c717a7ea1d diff --git a/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.md b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.md new file mode 100644 index 0000000000..8445cb44be --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.md @@ -0,0 +1,41 @@ +# Agent Note: Claimed commands as atomic composer chips + +Status: implemented + +English | [中文](2026-10-09-composer-command-chip.zh.md) + +## Problem + +The [Lexical composer decision](../../archived/architecture/2026-08-20-web-composer-lexical-editor.md) kept a claimed command as literal leading text. A TextNode transform styled the token and split typed overflow back out of it, and the submit machine held the claim while the draft string started with the token. Every edit at the token boundary ran through that transform. Lexical merges same-style neighbours before the overflow node's own transform runs, so the split and the merge looped until Lexical aborted the update with error #14 and the DOM diverged from the editor state (issue #4490). PR #5480 cleared the copied style for the reported gesture; pasting an image after a claim and IME composition reach the same transform and remained unverified. The plain-text reference entity also needed a precedence rule to leave the claimed token alone. + +## Decision + +A claim replaces the leading trigger span with `CommandChipNode`, an atomic inline `DecoratorNode`, followed by the token's argument separator as an ordinary text node. + +- The node carries the `CommandClaim`, including its submit closure. History restores nodes with their fields, so undoing a chip deletion restores the claim. JSON export writes the token text; JSON import yields a plain text node because the closure has no JSON form. +- The detect projection counts the chip as one U+FFFC, so trigger detection never re-reads the command name. The clipboard projection is the token without its separator. The chip is not a reference occurrence. `EditorProjection.command` is the claim of a chip that opens the document. +- Outside a submit transaction, the machine's claim follows the document: `draft-changed` carries the leading chip's claim, so deleting or displacing the chip releases the claim and a history restore re-enters claimed. A failed command submission settles to the same rule, so it returns to claimed exactly when a chip still leads; a claim adjudicated from typed text at Enter has no chip and returns to plain. +- Node transforms keep the chip on the leading seat. A chip with a preceding sibling or inside a later block becomes its literal token, which releases the claim as typing before the old token did. The chip's own transform sees sibling insertions and moves between blocks; a paragraph transform sees a block inserted before the chip's block, so no update walks the whole document. +- Deletion takes two steps. A character deletion that reaches the chip from a collapsed caret marks it selected (`data-selected` on its host; the root's `data-composer-chip-selected` hides the caret), and the next deletion in the same direction removes it whole. Deletions are decided both on the character-deletion command and on deletion input events, because iOS applies a ranged `deleteContentBackward` without that command. A key whose dispatch produced no character deletion, a click, blur, or a caret that leaves the chip clears the mark, so platform deletion shortcuts such as macOS Ctrl+H keep it. The editor selection stays a collapsed range and `isKeyboardSelectable()` stays false for the reason recorded on reference chips. Escape still does not release a claim. +- The persisted draft records the leading chip's claim token (`DraftSnapshot.command`), never the claim. On restore the input asks the trigger pipeline's `resolveClaim`, which polls the optional source hook `matchClaim`: it awaits the source's catalog and answers only a claim, never invoking or executing the command, unlike Enter's `matchEnter`. The chip is rebuilt only at the revision the restore produced and merged into the seed's history entry; an earlier edit, an unclaimed token, or a catalog failure keeps the literal token, which Enter still adjudicates. +- `claim-decor.ts` and the text-reference precedence rule are deleted. + +The `slash/input-*` events, `CommandClaim`, `InputState.claim`, and trigger sources keep their fields. `CommandClaim.token` still ends with the separator, and command arguments are still read from the clipboard text after it. + +## Alternatives considered + +**Keep literal text and repair each gesture.** Every gesture that reaches the boundary node needs its own proof, as #5480 showed, and the precedence rule with text references remains. + +**Reuse `ReferenceChipNode`.** Reference chips are occurrences serialized through their owner codec at submit; a command is not model content and must not enter `occurrences`. + +**Put the separator inside the chip text.** Lexical appends a managed line break after an inline decorator that ends its block, so the blank-arguments hint rendered by `p:last-child::after` would move to the next line. A separate text node keeps the hint on the chip's line. + +**Select the chip with a NodeSelection.** The plain-text binding's arrow, typing, and deletion handlers ignore non-range selections, so each of those gestures would need its own handling at the chip. + +**Keep claims in a shell map keyed by NodeKey.** The map duplicates the field the node already carries and needs pruning whenever history is cleared. + +## Consequences + +- Backspace no longer edits the command name character by character to reopen the menu. At the chip's edge, two Backspace or Delete presses remove the chip and leave the arguments as plain text. +- A reload restores the chip after the command catalog loads; until then, and when restoration does not apply, the draft shows the literal token. +- Whole-chip deletion through a real key event is observable only in the browser lane because jsdom lacks `Selection.modify`; `lifecycle-chrome.e2e.ts` pins the separator, select, and delete presses. diff --git a/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.zh.md b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.zh.md new file mode 100644 index 0000000000..268e0a7209 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-composer-command-chip.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 已认领命令作为输入框原子 chip + +Status: implemented + +[English](2026-10-09-composer-command-chip.md) | 中文 + +## 问题 + +[Lexical 输入框决策](../../archived/architecture/2026-08-20-web-composer-lexical-editor.md)把已认领命令保留为行首字面文本。一个 TextNode transform 给 token 加样式,并把输入进去的溢出文字拆出来;提交状态机在草稿字符串以 token 开头时保持认领。token 交界处的每次编辑都会经过这个 transform。Lexical 会在溢出节点自身的 transform 执行前合并样式相同的相邻节点,于是拆分与合并循环往复,直到 Lexical 以错误码 14 中止这次更新,DOM 与编辑器状态随之分叉(issue #4490)。PR #5480 针对报告的手势清除了被复制的样式;认领后粘贴图片和 IME 组字会进入同一个 transform,仍未验证。纯文本引用实体也需要一条优先级规则来避开已认领的 token。 + +## 决策 + +认领把行首触发区间替换为 `CommandChipNode`(一个原子 inline `DecoratorNode`),其后是作为普通文本节点的 token 参数分隔符。 + +- 节点携带 `CommandClaim`,包括其 submit 闭包。历史记录恢复节点时连同字段一起恢复,因此撤销 chip 删除会恢复认领。JSON 导出写入 token 文本;闭包没有 JSON 形式,因此 JSON 导入得到纯文本节点。 +- 检测投影把 chip 计为一个 U+FFFC,触发检测不会再读到命令名。剪贴板投影是不含分隔符的 token。chip 不是引用 occurrence。`EditorProjection.command` 是位于文档开头的 chip 所携带的认领。 +- 在提交事务之外,状态机的认领跟随文档:`draft-changed` 携带行首 chip 的认领,因此删除或挤开 chip 会释放认领,历史恢复会重新进入 claimed。命令提交失败后按同一规则结算,只要行首仍有 chip 就回到 claimed;Enter 时从输入文本裁决出的认领没有 chip,回到 plain。 +- 节点 transform 让 chip 保持在行首位置。前面有兄弟节点或位于后续块中的 chip 会变回字面 token,并像在旧 token 前输入那样释放认领。chip 自身的 transform 覆盖兄弟节点插入和跨块移动;段落 transform 覆盖在 chip 所在块之前插入块的情况,因此没有任何更新需要遍历整个文档。 +- 删除分两步。从折叠光标出发、方向触及 chip 的字符删除先把它标记为选中(宿主元素上的 `data-selected`;根元素的 `data-composer-chip-selected` 隐藏光标),同方向的下一次删除才整体移除它。字符删除命令和删除类输入事件都会参与判断,因为 iOS 处理带范围的 `deleteContentBackward` 时不经过该命令。分发过程中没有产生字符删除的按键、点击、失焦或光标离开 chip 会清除标记,因此 macOS Ctrl+H 这类平台删除快捷键会保留它。编辑器选区保持为折叠的 range,`isKeyboardSelectable()` 仍为 false,原因与引用 chip 记录的相同。Escape 仍不释放认领。 +- 持久化的草稿记录行首 chip 的认领 token(`DraftSnapshot.command`),从不保存认领本身。恢复时输入框调用触发管线的 `resolveClaim`,它轮询可选的 source 钩子 `matchClaim`:等待 source 的目录就绪,只应答认领,与 Enter 的 `matchEnter` 不同,从不调用或执行命令。chip 只在恢复产生的那个版本上重建,并合入种子的历史条目;在此之前发生的编辑、无人认领的 token 或目录加载失败都会保留字面 token,Enter 仍会对其裁决。 +- 删除 `claim-decor.ts` 和文本引用的优先级规则。 + +`slash/input-*` 事件、`CommandClaim`、`InputState.claim` 与触发源的字段保持不变。`CommandClaim.token` 仍以分隔符结尾,命令参数仍从其后的剪贴板文本读取。 + +## 考虑过的替代方案 + +**保留字面文本并逐个修复手势。** 如 #5480 所示,每个触及交界节点的手势都需要单独证明,与文本引用之间的优先级规则也仍然存在。 + +**复用 `ReferenceChipNode`。** 引用 chip 是在提交时经 owner codec 序列化的 occurrence;命令不是模型内容,不能进入 `occurrences`。 + +**把分隔符放进 chip 文本。** inline decorator 位于块末尾时,Lexical 会在其后追加受管换行,由 `p:last-child::after` 渲染的空参数提示会移到下一行。独立的文本节点让提示留在 chip 所在行。 + +**用 NodeSelection 选中 chip。** 纯文本绑定的方向键、输入和删除处理器都会忽略非 range 选区,这些手势在 chip 处都需要单独处理。 + +**在 shell 中用按 NodeKey 索引的表保存认领。** 该表重复了节点已携带的字段,并且每次清空历史时都需要修剪。 + +## 影响 + +- Backspace 不再逐字编辑命令名并重新打开菜单。在 chip 边缘按两次 Backspace 或 Delete 会删除 chip,参数保留为普通文字。 +- 刷新后,命令目录加载完成即恢复 chip;在此之前以及恢复不适用时,草稿显示字面 token。 +- 通过真实按键整体删除 chip 只能在浏览器 lane 中观察,因为 jsdom 缺少 `Selection.modify`;`lifecycle-chrome.e2e.ts` 固定了删除分隔符、选中、删除这三次按键。 diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml new file mode 100644 index 0000000000..3ae45a7509 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for 2026-10-14-webworker-preview-profile-parity.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-14-webworker-preview-profile-parity.md +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand: + en: f43aec03d4eeb831 + zh: fb40c3fd679876f3 +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/problem: + en: 0dd8324644e69400 + zh: 19d12f096078d72c +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/boot-the-same-profile-as-the-served-web-deployment: + en: caf1ca77192dcb20 + zh: d663e2cbb05fe4fc +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/node-internal-module-interfaces-in-the-worker: + en: 37d8e809eaa21d25 + zh: 8c7c31bd6f45c823 +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/inspector-degradation-on-platforms-without-worker-threads: + en: 608b24ca97ab5bca + zh: 94bb28377c856b2e +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/separate-archives-for-lazily-loaded-chunks: + en: 428699f74f19e998 + zh: 2e8fcdbc9616f70b +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/watching-and-client-plugin-delivery: + en: f3e6ba098433a70a + zh: 345b93f451d2768c +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/alternatives-considered: + en: 99ee505e82fa9c7c + zh: 448bc0e160424cbf +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/consequences: + en: 53d72faa09e78410 + zh: a5224e4662a4846b +/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/testing: + en: 532918df1acd147d + zh: e476c090d183d0a4 diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md new file mode 100644 index 0000000000..47b2866402 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md @@ -0,0 +1,105 @@ +# Agent Note: Boot the Preview Worker from the shipped profile and fetch lazy chunks on demand + +Status: implemented + +English | [中文](2026-10-14-webworker-preview-profile-parity.zh.md) + +## Problem + +The browser Preview ([webworker-runtime](../../../../packages/experimental/webworker-runtime/README.md), [webworker-packer](../../../../packages/experimental/webworker-packer/README.md)) can receive its boot configuration at pack time: the packer runs `dsh --profile web --dump-config` against a temporary home and writes the output to the image's `config/cordis.yml`; the Worker then inserts the whole configuration back as the profile's own patch (one `insert`). Measured on such an image, this has three consequences: + +| Symptom | Cause | +|---|---| +| The Plugins page shows no optional bundle | The image only collects rows that appear in the dump, so none of the 11 default-off `OPTIONAL_BUNDLES` is included; the Worker's `installAnchor` points at the profile's own package.json, which declares no installation dependencies | +| Even when added, some bundles do not take effect after they are enabled | Bundle layers apply before the profile patch, when the base rows do not exist yet: the session-titles disable patch is skipped, agent-team produces 4 `entry not found` warnings, and rows a bundle inserts move to the front | +| The image is 12.79 MiB | 4 lazily loaded chunks (documentpreview's pdf, excel, and frontmatter chunks and sidebar-terminal's terminal chunk, about 4.4 MiB together) download together with the code needed at startup | + +Measured: with no bundle enabled, the two compositions differ in only one of 188 rows, the `compression: none` setting of `session-persistence-jsonl`. The differences come from how the configuration is layered, not from the data itself. + +## Decision + +### Boot the same profile as the served Web deployment + +The served launch path (apps/cli, app-boot) carries no Preview-specific code; the webworker packages reproduce it: + +- **packer** + - Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from app-boot, the same source the Plugins page uses. + - The image carries a manifest-only `node_modules/@deepseek-ai/dsh/package.json` (a verbatim copy of `apps/cli/package.json`) as the installation anchor, and the image manifest names it in `installation`. + - The image carries no composed configuration and no `config/cordis.yml`; the image manifest records the `profile` the Worker boots. + - The image carries a home patch, `home/cordis.patch.yml`: for each row the runtime's `PLATFORM_ENTRY_CONFIG` names (`session-persistence-jsonl`), the config the template's bundle layers compose with the required fields over it (`compression: none`). A patch replaces a row's whole config, so the composed fields stay; the bundle files read at pack time are the ones in the image. +- **Worker** + - The image's app-boot `loadProfile` initializes `home/profiles/` from the template, the Worker writes an empty root `cordis.yml`, and `readProfilePatches` composes the layers. + - profileContext is filled the way `runProfile` fills it: name, dir, patchPath, installAnchor, startedBundles, cwd, home, and an empty overlay list, so the Worker calls `readProfilePatches` once, as the launcher does without `--patch`. + - The Worker provides a launchEnvironment snapshot and mounts `PluginPackages` without a resolution. +- **Retained platform differences**: jsonl `compression: none` through the image's home patch (ConfigEditor rejects edits to that row's config, as it rejects any row a home patch overrides), the module loader (the Worker's own CommonJS loader, provided through the Node internal-module interfaces in the next section), log output, command-line arguments, appReady timing, no pnpm, and no process-level proxy or signal handling. + +The Worker uses existing app-boot exports; app-boot has no Preview override or injection point. + +### Node internal-module interfaces in the Worker + +Plugin metadata reads (`readPluginMeta`), configuration schema dumps, and HMR do not go through ctx: they take Node internal modules directly through `ModuleLoader.fromInternal()` or `node-addon-require-builtin.requireBuiltin(id)`. A Worker that hides those modules, for example by reporting `process.versions.node` as `0.0.0` so that `fromInternal()` returns undefined, breaks these callers once `PluginPackages` is mounted: every bundle on the Plugins page reports "Plugin metadata requires the Node module resolver", and HMR, which finds no `loadCache`, returns `restart-required` for every switch. + +- **Interfaces only**: the Worker replaces `node-addon-require-builtin` with a static module whose `requireBuiltin(id)` answers the 5 modules callers read, under Node v24.18.0 ids, with Node's member names and signatures; every other id throws `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`. `process.versions.node` reports `24.18.0`, and `execArgv` carries no `--expose-internals`. +- **`internal/modules/esm/loader`**: the cascaded loader's `resolveSync(parentURL, request)` and `import` delegate to the Worker's existing loader, and failed resolution carries Node's `ERR_MODULE_NOT_FOUND` or `ERR_PACKAGE_PATH_NOT_EXPORTED`; `loadCache` is an empty `LoadCache`; `getOrCreateModuleJob` exists and refuses when called. The Worker loader instance owns the cascaded loader. +- **Members HMR replaces**: `Module` from `internal/modules/cjs/loader`, `package_json_reader`, the `modules` binding from `internal/bootstrap/realm`, and `ResolveCache` from `module_map` keep Node's member names so HMR can replace and restore them; they refuse when called. The Worker's own `require` does not route through them. +- **One cascaded loader**: the Worker does not override `ctx.loader.internal`; the Cordis Loader and static callers reach one cascaded loader through `fromInternal()`. +- **Node behavior for measured callers**: `node:path.win32.isAbsolute` (icon path validation) and `node:util.inherits` (`http-errors`), both following Node's source. + +### Inspector degradation on platforms without worker_threads + +The Worker's structural stubs throw errors carrying `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`. The Inspector Host degrades only when its Worker fails to start with that code: it warns once, the bootstrap route answers 503, and it registers no DevTools route, injects nothing, and provides no `inspector` service; every other startup failure fails the fiber. `serve-static` and `open` load after the Worker starts, so a degraded start never loads them. Session Inspector in the same bundle therefore runs. + +### Separate archives for lazily loaded chunks + +- **Classification**: the client build preset emits package-local chunk requests only as `require.async("./client..js")`, and `verify-client-packages` forbids synchronous chunk requires. The packer's `lazyChunksOf` scans every page asset in the swept image (each package's `lib/client.js` and `lib/client.*.js`) for these literals, so a chunk that another chunk requests is found without following requests from the `./client` entry. A target that a package's `./client` export names under any condition (such as `client.browser.js`) loads eagerly and stays in the base image; every other target leaves it. A request for a chunk the package does not publish fails the pack. +- **Archives**: each chunk gets one deterministic tar.gz named `lazy/.tar.gz` after the SHA-256 of the compressed archive. The base manifest's `lazy` list records each archive as `{ archive, files: [{ path, size }] }`, with no file mode. There is no size threshold. +- **Registration**: the Worker registers each listed file with `MemoryVfs.deferArchive`, which creates its parent directories and gives it the VFS default file mode; an image that lists lazy archives must be fetched from a URL, against which the archive names resolve. `exists`, `stat`, and `readdir` answer synchronously from the registration. +- **Fetch**: the asynchronous faces of the Worker's `node:fs` and `node:fs/promises` that read a registered file wait for its archive: `fs.promises.readFile`, `fs.promises.open` (so every FileHandle opens after arrival), `fs.promises.cp`, and `fs.createReadStream`. The first of them fetches the archive, inflates it, checks that it carries exactly the registered paths at the registered sizes, and materializes the files; concurrent readers share one fetch. The digest in the archive name is not verified. A failed fetch fails only the reads waiting on it; the files stay registered and the next asynchronous read fetches again. +- **Before arrival**: synchronous reads, and writes, appends, truncation, chmod, renames, and links in their synchronous and promise forms, are refused with `ERR_DSH_VFS_DEFERRED`, naming the archive; a directory rename that would move a registered file is refused before the destination changes, so the destination stays as it was. Unlink and recursive removal drop the registration, and seeding the path replaces it; an archive that arrives afterwards leaves those paths as they are. +- **client-modules**: `chunkResponse` reads a chunk with `fs.promises.readFile` inside `lazyBody`, which shares one read among concurrent requests and drops a rejected read once it settles, so the next request reads again. Under Node the asynchronous read returns the same bytes as a synchronous one. + +### Watching and Client plugin delivery + +- Worker `fs.watch` and `fs.watchFile` listeners run in the async context where the watcher was created, as Node delivers watch events from the watch handle, not in the context of the code whose write produced the event. HMR's configuration watcher therefore runs outside the writer's HMR transaction, and a configuration write made inside a transaction does not make the follow-up reload fail as a nested transaction. +- The browser half of `@deepseek-ai/dsh-client-hmr` reads `/plugins/events` through the page's `__DSH_TRANSPORT__.fetch` when a shell provides one (the Worker preview tunnel), otherwise through `EventSource`. The Client plugins of a bundle enabled in the Preview therefore mount without a page reload. + +## Alternatives considered + +**Keep the pack-time composition and wrap it in a synthetic base bundle inside the Worker.** The change is smaller, but the synthetic bundle appears in the plugin list, the self-composed pack-time configuration remains, and the Preview still differs from the served Web deployment. + +**Fetch chunks with synchronous XHR in the Worker.** client-modules stays unchanged, but the whole Host tree blocks while the download runs. + +**Prefetch in the tunnel by recognizing chunk request URLs.** The tunnel would need a copy of client-modules' `/plugins//` routing rules. + +**Combine archives per package or above a size threshold.** Each `require.async` needs exactly one file, so one archive per chunk fits best; a threshold would be a tunable with no supporting evidence. + +**Port Node v24's CommonJS loader and resolver into the Worker and replace the Worker's whole `require` pipeline.** Resolution would match Node exactly, but it requires Node's condition set (changing image contents), cjs-module-lexer for named exports, and about 300 KB more in worker.js, while callers need only the internal-module interfaces. + +**Inject a resolver into `readPluginMeta`.** It changes an app-boot public function to accommodate a platform difference, and other `fromInternal()` callers such as the configuration schema dump still get no loader. + +**Emulate `--expose-internals`.** Without that flag, the vendored Loader, HMR, and app-boot all fall back to `node-addon-require-builtin`, so replacing that package covers every caller. + +## Consequences + +The Preview boots the composition the served Web deployment boots. With no bundle enabled, the Worker composition differs from the served Web composition only by the jsonl platform override, and enabling any bundle skips no patch. The Plugins page and Settings → Built-in plugins list every `OPTIONAL_BUNDLES` entry with its title and icon and no metadata error. Switching each of the 11 optional bundles on and then off returns `applied` both times; after enabling, every enabled row it changes is active, and after disabling, the plugin list returns to its prior rows. Enabling session-titles disables `@deepseek-ai/dsh-session-title-first-prompt-llm` (row `session-title-llm`) and enables `@deepseek-ai/dsh-session-title-all-prompts-llm`, as in the served Web deployment; enabling inspector-profile runs Session Inspector and starts the NodeJS Inspector degraded. Degradation requires `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, so a served `dsh web` runs the full Inspector. + +Size (MiB, measured): the base image is 9.20 (12.79 with pack-time composition and eager chunks, −28%) including 11 optional bundles, plus 5 lazy tar archives totaling 4.45 (pdf 2.88, excel 1.39, terminal 0.13, frontmatter 0.05, Inspector bottom panel 0.00). The base image contains no lazily loaded chunk, and the page fetches an archive only when it requests the chunk. + +The trade-off costs: + +- HMR replaces no modules: the Worker's ES module cache stays empty, so a module file changed in the VFS keeps running its loaded code while HMR reports `applied`. The Preview image changes no module files (package operations fail with the `execa` refusal). +- The Node internal-module members that exist only by name refuse when called; a future caller that invokes them fails explicitly in the Preview. +- Reporting `process.versions.node` as `24.18.0` can change third-party code that branches on the version; a scan of the image found only undici selecting wasm SIMD by version on ppc64, which does not apply to the Worker. +- CommonJS `require('stream')` returns the module namespace rather than the `Stream` constructor, so code that passes it to `util.inherits` fails at load. +- Capabilities in optional bundles that cannot run in a browser (sqlite, terminals, local speech recognition) fail when used: enabling them reports no error, and using them does. A degraded NodeJS Inspector keeps only its bootstrap route, which answers 503. +- The image layout carries no `config/cordis.yml`, `WorkerHostOptions` has no `configPath`, and an image manifest without `profile` fails validation, so an image packed with a composed configuration must be repacked. +- Deferred files require readers to use the waiting asynchronous faces; a new synchronous reader fails explicitly in the Preview instead of degrading. The Worker checks only the registered paths and sizes of an arrived archive, not the digest that names it. + +## Testing + +- [profile-image.spec.ts](../../../../packages/experimental/webworker-packer/tests/profile-image.spec.ts) pins the roster (template bundles, every optional bundle, and the packages their patch rows name) and an image that records the profile and installation and carries only the installation manifest. +- [lazy-chunks.spec.ts](../../../../packages/experimental/webworker-packer/tests/lazy-chunks.spec.ts) pins one archive per `require.async` chunk, identical archive names and bytes across packs, refusal of an unpublished chunk, and `./client` export targets kept in the base image. +- [lazy-archive.spec.ts](../../../../packages/experimental/webworker-runtime/tests/storage/lazy-archive.spec.ts) pins registration answers, `ERR_DSH_VFS_DEFERRED` refusals, the unchanged rename destination, removal and seeding, one shared fetch, retry after a failed fetch, and the waiting `node:fs` faces. +- [require-builtin.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/require-builtin.spec.ts) pins the provided internal modules, refusal of other ids with the platform code, the cascaded loader, the members HMR replaces, and the reported Node version; [fs-watch-context.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/fs-watch-context.spec.ts) pins watcher listeners running in the watcher's creation context. +- [bootstrap-route.host.spec.ts](../../../../packages/experimental/inspector/tests/bootstrap-route.host.spec.ts) pins Inspector degradation; [transport.client.spec.ts](../../../../packages/client/hmr/tests/transport.client.spec.ts) pins the page-transport event stream; [node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) pins the chunk read retry. +- [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) boots the packed Worker deployment, requests a lazy chunk through the tunnel, switches every optional bundle on and off, checks the session-titles and inspector-profile rows, and mounts an enabled bundle's Client plugin without a reload. The image sizes are measured, not pinned by a test. diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md new file mode 100644 index 0000000000..018992e103 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md @@ -0,0 +1,105 @@ +# Agent Note: Preview Worker 按正式 profile 启动,懒加载 chunk 按需拉取 + +Status: implemented + +[English](2026-10-14-webworker-preview-profile-parity.md) | 中文 + +## 问题 + +浏览器 Preview([webworker-runtime](../../../../packages/experimental/webworker-runtime/README.zh.md)、[webworker-packer](../../../../packages/experimental/webworker-packer/README.zh.md))的启动配置可以在打包时生成:packer 用临时 home 运行 `dsh --profile web --dump-config`,把输出写进镜像的 `config/cordis.yml`;Worker 再把整份配置当作 profile 自己的补丁(一条 `insert`)插回去。在这样的镜像上实测,会带来三个后果: + +| 现象 | 原因 | +|---|---| +| 插件页看不到任何 optional bundle | 镜像只收 dump 里出现的行,11 个默认关闭的 `OPTIONAL_BUNDLES` 都不在里面;Worker 的 `installAnchor` 指向 profile 自己的 package.json,没有安装依赖 | +| 即使补上,启用后有的 bundle 也不生效 | bundle 层排在 profile 补丁前面,基础行这时还不存在:session-titles 的禁用补丁被跳过,agent-team 产生 4 条 `entry not found`,bundle 新增的行排到了最前面 | +| 镜像 12.79 MiB | 4 个懒加载 chunk(documentpreview 的 pdf、excel、frontmatter,sidebar-terminal 的 terminal,共约 4.4 MiB)和启动必需的代码一起下载 | + +实测:不开 bundle 时,两边组合的 188 行里只有 `session-persistence-jsonl` 的 `compression: none` 一处不同。差异来自配置的分层方式,不在数据本身。 + +## 决策 + +### 与正式 Web 相同的 profile 启动 + +正式启动链路(apps/cli、app-boot)不含 Preview 专用代码;由 webworker 包复现这条链路: + +- **packer** + - 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 app-boot 读,与插件页同一来源。 + - 镜像里放一份只含清单的 `node_modules/@deepseek-ai/dsh/package.json`(原样复制 `apps/cli/package.json`),作为安装锚点,镜像 manifest 在 `installation` 里记录它。 + - 镜像不带组合好的配置,也没有 `config/cordis.yml`;镜像 manifest 记录 Worker 要启动的 `profile`。 + - 镜像带一份 home patch `home/cordis.patch.yml`:对运行时 `PLATFORM_ENTRY_CONFIG` 列出的每一行(`session-persistence-jsonl`),写入模板各 bundle 层合成出的配置,并在其上设置所需字段(`compression: none`)。补丁会整体替换一行的配置,所以保留合成出的字段;打包时读取的 bundle 文件就是镜像里的那些。 +- **Worker** + - 用镜像里 app-boot 的 `loadProfile` 从模板初始化 `home/profiles/`,写空的根 `cordis.yml`,用 `readProfilePatches` 组合。 + - profileContext 按 `runProfile` 填写:name、dir、patchPath、installAnchor、startedBundles、cwd、home,overlay 列表为空,因此 Worker 与不带 `--patch` 的启动器一样只调用一次 `readProfilePatches`。 + - 提供 launchEnvironment 快照,挂上不带 resolution 的 `PluginPackages`。 +- **保留的平台差异**:jsonl `compression: none`(经镜像的 home patch 提供;ConfigEditor 会拒绝修改这一行的配置,与拒绝任何被 home patch 覆盖的行一致)、模块加载器(Worker 自己的 CommonJS 加载器,经 Node internal 模块接口提供,见下节)、日志输出、启动参数、appReady 时机、没有 pnpm、没有进程级代理和信号处理。 + +Worker 只用 app-boot 现有导出;app-boot 没有 Preview 专用的 override 或注入点。 + +### Worker 提供 Node internal 模块接口 + +读插件元信息(`readPluginMeta`)、配置 schema 导出和 HMR 不经过 ctx,而是用 `ModuleLoader.fromInternal()` 或 `node-addon-require-builtin.requireBuiltin(id)` 直接取 Node 的 internal 模块。如果 Worker 藏起这些模块,例如把 `process.versions.node` 报成 `0.0.0` 让 `fromInternal()` 返回空,挂上 `PluginPackages` 后这些调用方就会出错:插件页每个 bundle 都报“Plugin metadata requires the Node module resolver”,HMR 看不到 `loadCache`,每次开关都返回 `restart-required`。 + +- **只照搬接口**:Worker 用静态模块替换 `node-addon-require-builtin`,`requireBuiltin(id)` 按 Node v24.18.0 的 id 返回调用方实际读取的 5 个模块,成员名和签名与 Node 一致;其余 id 抛 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`。`process.versions.node` 报 `24.18.0`,`execArgv` 不带 `--expose-internals`。 +- **`internal/modules/esm/loader`**:cascaded loader 的 `resolveSync(parentURL, request)` 和 `import` 交给 Worker 现有的加载器,解析失败带 Node 的 `ERR_MODULE_NOT_FOUND`、`ERR_PACKAGE_PATH_NOT_EXPORTED`;`loadCache` 是空的 `LoadCache`;`getOrCreateModuleJob` 存在但调用即拒绝。cascaded loader 由 Worker 加载器实例持有。 +- **HMR 替换的成员**:`internal/modules/cjs/loader` 的 `Module`、`package_json_reader`、`internal/bootstrap/realm` 的 `modules` binding、`module_map` 的 `ResolveCache` 保留 Node 的成员名,供 HMR 替换与还原;被调用时拒绝。Worker 自己的 `require` 不经过它们。 +- **同一个 cascaded loader**:Worker 不覆写 `ctx.loader.internal`;Cordis Loader 与静态调用方经 `fromInternal()` 拿到同一个 cascaded loader。 +- **按实测调用方补齐的 Node 行为**:`node:path.win32.isAbsolute`(图标路径校验)、`node:util.inherits`(`http-errors`),均按 Node 源码实现。 + +### Inspector 在没有 worker_threads 的平台上降级 + +Worker 的结构化 stub 抛出带 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 的错误。Inspector Host 只在 Worker 启动以这个错误码失败时降级:警告一次,bootstrap 路由返回 503,不注册 DevTools 路由、不注入、不提供 `inspector` 服务;其他启动错误让 fiber 失败。`serve-static` 和 `open` 在 Worker 启动成功后才加载,降级时不加载。同一 bundle 里的 Session Inspector 因此正常运行。 + +### 懒加载 chunk 独立归档 + +- **分类**:client 构建预设只以 `require.async("./client..js")` 的形式产出包内 chunk 请求,`verify-client-packages` 禁止同步 require chunk。packer 的 `lazyChunksOf` 扫描清扫后镜像里的每个页面资源(各包的 `lib/client.js` 和 `lib/client.*.js`)中的这些字面量,因此 chunk 引用的 chunk 不必从 `./client` 入口沿请求追踪也能找到。在任一条件下被某个包的 `./client` 导出指向的目标(如 `client.browser.js`)会被立即加载,留在基础镜像;其余目标移出基础镜像。请求包未发布的 chunk 会让打包失败。 +- **归档**:每个 chunk 一个确定性 tar.gz,按压缩后归档的 SHA-256 命名为 `lazy/.tar.gz`。基础 manifest 的 `lazy` 列表把每个 archive 记录为 `{ archive, files: [{ path, size }] }`,不记录文件 mode。不设大小阈值。 +- **登记**:Worker 用 `MemoryVfs.deferArchive` 登记列出的每个文件,同时创建其父目录,并赋予 VFS 默认文件 mode;列出懒加载 archive 的镜像必须从 URL 拉取,archive 名相对这个 URL 解析。`exists`、`stat`、`readdir` 依据登记同步作答。 +- **拉取**:Worker 的 `node:fs` 和 `node:fs/promises` 中读取已登记文件的异步接口会等待其 archive:`fs.promises.readFile`、`fs.promises.open`(因此每个 FileHandle 都在到达后才打开)、`fs.promises.cp` 和 `fs.createReadStream`。其中第一个调用拉取 archive、解压,校验其中恰好是登记的路径和登记的大小,再物化文件;并发读取方共用一次拉取。archive 名里的摘要不做校验。拉取失败只让等待它的读取失败;文件保持登记,下一次异步读取再次拉取。 +- **到达之前**:同步读取,以及同步和 promise 形式的写入、追加、截断、chmod、重命名和硬链接,都以 `ERR_DSH_VFS_DEFERRED` 拒绝,并给出 archive 名;会移动已登记文件的目录重命名在目标改变之前就被拒绝,目标保持原样。unlink 和递归删除会撤销登记,对该路径 seed 会替换登记;之后到达的 archive 不改动这些路径。 +- **client-modules**:`chunkResponse` 在 `lazyBody` 里用 `fs.promises.readFile` 读取 chunk;`lazyBody` 让并发请求共用一次读取,被拒绝的读取一旦落定就丢弃,下一次请求重新读取。在 Node 下,异步读取与同步读取得到相同字节。 + +### 文件监听与 Client 插件下发 + +- Worker 的 `fs.watch` 和 `fs.watchFile` 监听器在创建 watcher 时的异步上下文里运行,与 Node 从 watch 句柄派发事件一致,而不是在写入方的上下文里运行。因此 HMR 的配置 watcher 在写入方的 HMR 事务之外运行,事务内的配置写入不会让随后的重载因嵌套事务而失败。 +- `@deepseek-ai/dsh-client-hmr` 的浏览器部分在 shell 提供 `__DSH_TRANSPORT__.fetch` 时(Worker preview 隧道)经它读取 `/plugins/events`,否则经 `EventSource` 读取。因此在 Preview 里启用 bundle 后,其 Client 插件无需刷新页面即可挂载。 + +## 考虑过的替代方案 + +**保留打包期组合,只在 Worker 里包成一个合成 base bundle。** 改动小,但合成 bundle 会出现在插件列表里,打包期“自己编”的配置也还在,与正式 Web 不同构。 + +**Worker 用同步 XHR 拉 chunk。** 不改 client-modules,但下载期间整棵 Host 树都被阻塞。 + +**隧道按 URL 识别 chunk 请求后预取。** 需要在隧道里复制 client-modules 的 `/plugins//` 路由规则。 + +**按包或按大小阈值合并 archive。** 每次 `require.async` 只要一个文件,按 chunk 拆分最贴合;阈值是没有依据的可调参数。 + +**把 Node v24 的 CJS 加载器和解析器源码搬进 Worker,替换 Worker 的整条 require 流程。** 解析路径可以与 Node 逐字一致,但要改用 Node 的条件集(镜像内容随之变化)、引入 cjs-module-lexer 计算具名导出,worker.js 约增加 300 KB;而调用方只需要 internal 模块的接口。 + +**给 `readPluginMeta` 注入解析器。** 要改 app-boot 的公共函数来适配一个平台差异,配置 schema 导出等其他 `fromInternal()` 调用方仍然拿不到加载器。 + +**模拟 `--expose-internals`。** vendor Loader、HMR 和 app-boot 在没有这个参数时都退到 `node-addon-require-builtin`,替换这个包即可覆盖全部调用方。 + +## 后果 + +Preview 启动的组合与正式 Web 相同。不开 bundle 时,Worker 组合与正式 Web 只差 jsonl 的平台覆盖;开任一 bundle 都没有被跳过的补丁。插件页和“设置 → 内置插件”列出全部 `OPTIONAL_BUNDLES`,每个都显示标题和图标,没有元信息错误。11 个 optional bundle 逐个开启再关闭,两次都返回 `applied`;开启后它改动的每个启用行都是 active,关闭后插件列表恢复原样。启用 session-titles 会禁用 `@deepseek-ai/dsh-session-title-first-prompt-llm`(行 `session-title-llm`)并启用 `@deepseek-ai/dsh-session-title-all-prompts-llm`,与正式 Web 一致;启用 inspector-profile 后 Session Inspector 运行,NodeJS Inspector 降级启动。降级以 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 为前提,所以正常 `dsh web` 运行完整的 Inspector。 + +体积(MiB,实测):基础镜像 9.20(打包期组合且 chunk 随启动下载时为 12.79,−28%),含 11 个 optional bundle;另有 5 个 lazy tar 共 4.45(pdf 2.88、excel 1.39、terminal 0.13、frontmatter 0.05、inspector 底部面板 0.00)。基础镜像不含懒加载 chunk,页面请求 chunk 时才拉取对应 archive。 + +这一取舍的代价: + +- HMR 不替换模块:Worker 的 ES module 缓存始终为空,VFS 里被修改的模块文件仍运行已加载的代码,而 HMR 照常报告 `applied`。Preview 镜像不会修改模块文件(包操作会因 `execa` 拒绝而失败)。 +- Node internal 模块里只提供成员名的部分被调用时拒绝;将来有调用方真正调用它们,会在 Preview 里明确失败。 +- `process.versions.node` 报 `24.18.0` 可能改变按版本分支的第三方代码;扫描镜像只发现 undici 在 ppc64 上按版本选择 wasm SIMD,与 Worker 无关。 +- CommonJS 的 `require('stream')` 返回模块命名空间而不是 `Stream` 构造函数,把它传给 `util.inherits` 的代码会在加载时失败。 +- optional bundle 里浏览器跑不了的能力(sqlite、终端、本地语音)按“跑到才炸”处理:启用不报错,用到时报错。NodeJS Inspector 降级后只剩返回 503 的 bootstrap 路由。 +- 镜像布局里没有 `config/cordis.yml`,`WorkerHostOptions` 没有 `configPath`,缺少 `profile` 的镜像 manifest 无法通过校验,所以带组合配置打出的镜像必须重新打包。 +- 延迟文件要求读取方走会等待的异步接口;新增的同步读取方会在 Preview 里明确失败,而不是退化。Worker 只校验到达的 archive 中登记的路径和大小,不校验命名它的摘要。 + +## 测试 + +- [profile-image.spec.ts](../../../../packages/experimental/webworker-packer/tests/profile-image.spec.ts) 锁定收包清单(模板 bundle、全部 optional bundle、它们补丁行指向的包),以及记录 profile 和 installation、只带安装清单的镜像。 +- [lazy-chunks.spec.ts](../../../../packages/experimental/webworker-packer/tests/lazy-chunks.spec.ts) 锁定每个 `require.async` chunk 一个 archive、多次打包得到相同的 archive 名和字节、拒绝未发布的 chunk、`./client` 导出目标留在基础镜像。 +- [lazy-archive.spec.ts](../../../../packages/experimental/webworker-runtime/tests/storage/lazy-archive.spec.ts) 锁定登记后的作答、`ERR_DSH_VFS_DEFERRED` 拒绝、重命名目标保持不变、删除与 seed、共用一次拉取、拉取失败后重试,以及会等待的 `node:fs` 接口。 +- [require-builtin.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/require-builtin.spec.ts) 锁定提供的 internal 模块、其他 id 以平台错误码拒绝、cascaded loader、HMR 替换的成员和报告的 Node 版本;[fs-watch-context.spec.ts](../../../../packages/experimental/webworker-runtime/tests/node/fs-watch-context.spec.ts) 锁定监听器在 watcher 创建时的上下文里运行。 +- [bootstrap-route.host.spec.ts](../../../../packages/experimental/inspector/tests/bootstrap-route.host.spec.ts) 锁定 Inspector 降级;[transport.client.spec.ts](../../../../packages/client/hmr/tests/transport.client.spec.ts) 锁定经页面 transport 读取事件流;[node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) 锁定 chunk 读取重试。 +- [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) 启动打包好的 Worker 部署,经隧道请求一个懒加载 chunk,逐个开关全部 optional bundle,检查 session-titles 和 inspector-profile 的行,并在不刷新页面的情况下挂载已启用 bundle 的 Client 插件。镜像体积是实测值,没有测试锁定。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index d4f615792e..b2f61d6ca2 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml @@ -9,14 +9,14 @@ en: 3238dfb7db7dfdd8 zh: ec3bdd9a9da0a321 /agent-note-user-authorized-subagent-model-routes/decision: - en: 0166e452a774ade5 - zh: 747061b9424b3610 + en: 9eeb42f10be1bd28 + zh: 24e6cca2db77444a /agent-note-user-authorized-subagent-model-routes/alternatives-considered: en: e41c17226190ea8b zh: d3b1395fafe75f01 /agent-note-user-authorized-subagent-model-routes/consequences: - en: f1927d31ffc1dee9 - zh: b0fed04b80043c71 + en: 26244ba796f6ed70 + zh: 47e9744719b05f0b /agent-note-user-authorized-subagent-model-routes/related-decisions: - en: 595a9485d91592c6 - zh: 7b32611ea380498c + en: 0eead87ec1172d81 + zh: 4e776a583f5a00a5 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index e7f3b4fcae..c47168e602 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -12,11 +12,11 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `session/modelCatalog`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. -A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a legacy Session without the event remains disabled, including an explicitly empty restored Session. +The Host settings service snapshots the route list for each fresh top-level Session in `subagent/model-selection-policy` when the setting is enabled, before its first model request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently created top-level Sessions. Without the event, including an explicitly empty restored Session, explicit selection remains restricted to the current parent route. -The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. +The fixed `list_subagent_models` schema does not enumerate the policy. At call time, it lists authorized registered providers and intersects their advertised model catalogs with the recorded route list. Exact lookup requires authorization before resolving model metadata and all supported reasoning efforts, including for ids absent from the catalog. Without a recorded policy, discovery resolves only the parent Agent’s current effective route, even if the catalog omits it. The delegation executor independently checks any explicit provider, model, or effort selection against the same authorization before adapter preflight. A call with no selection fields retains configured or inherited routing. -Model selection has no unrestricted static mode. The default-off Host setting is the only authority, and an enabled Session always carries an exact allowlist. The primary spawn tool reads that setting; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse. +Model selection has no unrestricted static mode. The default-off Host setting authorizes alternative child routes; without it, the parent route and its supported efforts remain selectable. Each delegation tool’s `modelSelection` option controls only its route fields. One delegation instance per scope enables `modelDiscovery` to own discovery; the setting, discovery, and delegation read the same package-local authorization projection. Web and Desktop presets mount discovery by default, enable spawn route fields, and leave fork route fields disabled to preserve inherited-prefix reuse; custom fork compositions may enable them under the same Session authorization. Tool installation does not sample or expand authorization. ## Alternatives considered @@ -33,11 +33,11 @@ Model selection has no unrestricted static mode. The default-off Host setting is ## Consequences - New adapter registrations and newly advertised models do not expand user authorization. -- Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it. -- The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. +- Adapter removal or catalog lookup failure can make provider discovery unavailable without deleting the saved route decision; an allowed model remains usable when its adapter accepts it even if the advisory catalog omits it. +- The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. The `modelSelection` option adds three route fields; `modelDiscovery` adds a fixed discovery schema. Both options default off; shipped headless and SDK profiles carry neither. - The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it. -- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged-route retention, connection-generation invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow. +- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, shared definitions, parent-route defaults, discovery restrictions, executor denial, and the settings UI’s staged writes and revision checks. The assembled Web scenario pins the real settings document and Plugins card flow. ## Related decisions -The route arguments, adapter preflight, discovery tool, and fork cache restriction remain owned by [model-selected subagent routes](../../../../packages/subagent/tool-subagent/README.md). +The route arguments, adapter preflight, discovery tool, and fork restriction remain owned by [model-selected subagent routes](../../../../packages/subagent/tool-subagent/README.md). diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index f55f60de1b..0ca908b24d 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -12,11 +12,11 @@ Status: implemented Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `session/modelCatalog` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 -设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而没有该事件的旧 Session 仍保持禁用,包括显式为空的恢复 Session。 +设置启用时,Host 设置服务为每个新顶层 Session 在首个模型请求前将路由列表记录为 `subagent/model-selection-policy`。事件存在表示选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一精确列表,恢复的 Session 使用已记录事件而非当前设置。因此设置变更只影响之后创建的顶层 Session。没有该事件时,包括显式为空的恢复 Session,显式选择仍仅限当前父级路由。 -固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 +固定的 `list_subagent_models` schema 不枚举策略。调用时,它列出已授权且已注册的提供方,并取其公布模型目录与已记录路由列表的交集。精确查询先要求授权,再解析模型元数据及全部支持的推理强度,也允许目录未列出的 ID。没有已记录策略时,发现只解析父 Agent 当前生效的路由,即使目录未列出它。委派执行器会在适配器预检前独立校验显式提供方、模型或强度选择是否符合相同授权。没有选择字段的调用保留配置或继承的路由。 -模型选择不再有无限制的静态模式。默认关闭的 Host 设置是唯一授权来源,启用的 Session 始终携带精确允许列表。主 spawn 工具读取该设置;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。 +模型选择没有无限制的静态模式。默认关闭的 Host 设置授权其他子级路由;没有该授权时,仍可选择父级路由及其支持的强度。各委派工具的 `modelSelection` 选项只控制自身路由字段。每个作用域的一个委派实例启用 `modelDiscovery` 并负责发现;设置、发现和委派读取包内同一个授权投影。Web 与 Desktop preset 默认挂载发现工具,启用 spawn 路由字段,并为 fork 保持关闭,以保留对继承前缀的复用;自定义 fork 组合可以在相同 Session 授权下启用这些字段。工具安装不采样或扩大授权。 ## Alternatives considered @@ -33,11 +33,11 @@ Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` ## Consequences - 新适配器注册和新公布模型不会扩大用户授权。 -- 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。 -- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。 +- 适配器移除或目录查询失败可能使提供方发现不可用,但不会删除已存路由决定;只要适配器接受某个获准模型,即使建议性目录省略它,该模型仍可使用。 +- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。`modelSelection` 选项增加三个路由字段;`modelDiscovery` 增加固定发现 schema。两个选项都默认关闭;随附 headless 与 SDK profile 两者都不携带。 - 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。 -- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存路由保留、连接换代失效、暂存后的整数组写入、陈旧 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 +- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、共享定义、父级路由默认值、发现限制、执行器拒绝,以及设置 UI 的暂存写入与 revision 检查。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 ## Related decisions -路由参数、适配器预检、发现工具与 fork 缓存限制仍由[模型选择的 subagent 路由](../../../../packages/subagent/tool-subagent/README.zh.md)负责。 +路由参数、适配器预检、发现工具与 fork 限制仍由[模型选择的 subagent 路由](../../../../packages/subagent/tool-subagent/README.zh.md)负责。 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 eb1e0d124a..f50ce14bc9 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 @@ -9,23 +9,23 @@ en: 492ba2355360d81a zh: 825c0e7ab1cf4beb /agent-note-experimental-auto-review-before-each-tool-call/decision: - en: 3868c5c4b087bdba - zh: c3dff98fc5f05f19 + en: d31f010f9c22c452 + zh: 5c3966a953227998 /agent-note-experimental-auto-review-before-each-tool-call/decision/effects-and-authority: - en: 55a79d8572fb4332 - zh: f63d03d0f3bb5811 + en: db0a2deb9193a00d + zh: dab5a65b88957464 /agent-note-experimental-auto-review-before-each-tool-call/decision/one-complete-reviewer-request: - en: 27d104af4b7ec02d - zh: 5f72acb67e7d1ef6 + en: 7bd9fef90e8639b9 + zh: e2a096885c9c1e83 /agent-note-experimental-auto-review-before-each-tool-call/decision/result-and-cancellation: - en: 237018634b5f9dcf - zh: cbc49d9c1b794065 + en: 647fe68c52b907e0 + zh: 267a739bad44ad65 /agent-note-experimental-auto-review-before-each-tool-call/decision/process-catalog-and-children: en: 0042c645360c8361 zh: 5dba39466d376559 /agent-note-experimental-auto-review-before-each-tool-call/alternatives-considered: - en: fe97128ed05b9e43 - zh: 4aaa344933fa89b4 + en: a7e7f25cf98215bc + zh: 1ddcabec2bba3539 /agent-note-experimental-auto-review-before-each-tool-call/consequences: en: 1aca35a6d0784f67 zh: 51cfa397db101600 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 99576fad7f..9434c92f41 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.md +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.md @@ -10,7 +10,7 @@ Full access lets useful project work proceed without repeated approvals, but it ## Decision -[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.md) is an explicitly installed experimental Web layer, published under the [experimental package publication policy](../../../../packages/experimental/README.md). Default Web retains Read Only, Workspace Write, and Full access. The layer contributes current-session `auto`, whose only durable identity is `permission/preset:auto`; it uses Full access's `danger-full-access` sandbox and tool definitions with the approval policy chosen by [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md). Headless, General settings, and new-session defaults exclude the integration. +[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.md) is an explicitly installed experimental profile layer, published under the [experimental package publication policy](../../../../packages/experimental/README.md). Default Web retains Read Only, Workspace Write, and Full access. The layer contributes current-session `auto`, whose only durable identity is `permission/preset:auto`; it uses Full access's `danger-full-access` sandbox and tool definitions with the approval policy chosen by [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md). Default Headless, General settings, and new-session defaults exclude the integration. [Headless caller admission](2026-10-18-headless-auto-review.md) defines explicit headless opt-in and transport-independent user-source authority. Every native call and started PTC `tools.*` inner call receives one review before its body. The outer `run_code` transport and direct Node effects in a PTC program remain outside this guarantee. There are no tool-name exemptions, cached grants, retries, configurable policy, or second authorization check; [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md) owns what follows a denial. A repeated call receives a fresh review. @@ -21,12 +21,12 @@ The fixed policy follows the allow/soft-deny/hard-deny distinction and classifie | Risk | Examples | Decision | | --- | --- | --- | | `low` | Ordinary project-local reads/writes, analysis, format/lint/test/build, non-destructive Git, and exact cleanup of an object whose retained calls establish creation in this Session | Allow | -| `medium` | Irreversible deletion of pre-existing state, force push/history rewrite, production reads/writes/deployment, non-sensitive external writes/sends, and permission/security/system changes | Allow only with explicit current human or direct-parent authorization naming action, exact target, and necessary scope | +| `medium` | Irreversible deletion of pre-existing state, force push/history rewrite, production reads/writes/deployment, non-sensitive external writes/sends, and permission/security/system changes | Allow only with explicit current user or direct-parent authorization naming action, exact target, and necessary scope | | `high` | Sensitive data exfiltration across the current trust boundary and equivalent hard-deny effects | Deny, including an explicitly requested action | -Actual effects that are ambiguous or broader than established scope fail closed. A later instruction clears an earlier conflict only by explicitly revoking or replacing it; direct-parent instructions cannot override human restrictions. History can establish a session-created object but cannot authorize medium work or remove a high-risk prohibition. +Actual effects that are ambiguous or broader than established scope fail closed. A later instruction clears an earlier conflict only by explicitly revoking or replacing it; direct-parent instructions cannot override user restrictions. History can establish a session-created object but cannot authorize medium work or remove a high-risk prohibition. -The reviewer derives authority from existing Session facts. A shipped Web human instruction has `source.kind === 'user'` and the `rpcId` written at browser prompt admission. A child's initial direct-parent prompt is identified after its own existing creation descriptor; a later `agent-message` is direct-parent instruction only when `senderSessionId` matches `SessionHeader.parentSession`. Human text defines or replaces the task and restrictions. Direct-parent text defines the child task within those restrictions. Project instructions only constrain; checkpoints restore lossy context; images, attachment metadata, and historical calls provide facts. Compaction does not promote a checkpoint into the human authority of text that left the surface. +The reviewer derives authority from existing Session facts. Text with `source.kind === 'user'` defines or replaces the task and restrictions, independent of its input transport. A child's initial direct-parent prompt is identified after its own existing creation descriptor; a later `agent-message` is direct-parent instruction only when `senderSessionId` matches `SessionHeader.parentSession`. Inherited and unverified child creation prompts remain facts. [User-source admission](2026-10-18-headless-auto-review.md) defines caller and restored-history semantics. Direct-parent text defines the child task within user restrictions. Project instructions only constrain; checkpoints restore lossy context; images, attachment metadata, and historical calls provide facts. Compaction does not promote a checkpoint into the instruction authority of text that left the surface. ### One complete reviewer request @@ -36,7 +36,7 @@ The integration uses only the latest `request/header.config` provider/model and | --- | --- | | System prompt `REVIEW_POLICY` | Fixed classification, source authority, message layout, and strict result rules; allow executes immediately with Full access and no later confirmation | | First user message `ENVIRONMENT` | Existing Session header `cwd` only | -| One user message per retained item | Current compaction surface's sourced human/direct-parent messages, project instructions with the constraint role, checkpoints, image/attachment facts, historical call names with logged arguments, and a `tool-schema` item before the first call that used each reviewed schema, in surface order | +| One user message per retained item | Current compaction surface's sourced user/direct-parent messages, project instructions with the constraint role, checkpoints, image/attachment facts, historical call names with logged arguments, and a `tool-schema` item before the first call that used each reviewed schema, in surface order | | Final user message | The pending call with its logged arguments; the latest earlier `tool-schema` item with its mode and name gives the tool description and parameter schema | Each item is its own message and keeps the same text in later reviews: the pending call becomes a historical call, and the integration remembers, for each live Session, the schema each reviewed call showed so that it reappears at the same position. A review's messages therefore begin with all messages of the previous review. Providers that cache only at message ends, such as OpenAI implicit breakpoints on GPT-5.6 and later, or at a final marker with lookback, such as Anthropic `cache_control`, reuse the previous review; one growing user message let them reuse only the policy. The schema memory is process-local, so the first review after a restart omits earlier schemas and misses the cache once. @@ -51,7 +51,7 @@ The reviewer may emit reasoning blocks followed by exactly one JSON text block a A final denial, which [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md) limits to the `never` approval policy, gives native results and PTC settle events the same structured `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` and optional raw reason. For a final denial the main agent receives only `Auto review rejected tool ""; its body was not executed` through ordinary failure rendering. PTC retains the existing program exception/catch behavior; catching a denial does not elevate it to an outer failure. The generic Web tool card supplies the denial identity for the collapsed row and one not-executed output line for the expanded row, with no input body. Only that display trims and collapses line separators or supplies the localized empty-reason fallback; persistence and both SDKs preserve the complete raw reason, without a new length or redaction rule. -Admission and active-review enrollment occur synchronously before the first await. The integration owns one lifecycle controller and one set of active operations. Unload closes new selection/review admission, changes live Auto Sessions to Full access through the existing preset writer, which writes the `never` approval policy without changing the sandbox value or closing terminals, then aborts and drains reviews before removing listener and contribution. After provider settlement, a lifecycle abort always produces canonical pre-dispatch cancellation, including late allow, deny, or failure. Caller cancellation retains ToolRuntime's priority: late allow cancels before dispatch; a late failure or final denial retains that outcome, and a late denial under `ask` produces a cancelled approval and canonical pre-dispatch cancellation. No cancelled review starts a tool body. +Admission and active-review enrollment occur synchronously before the first await. The integration owns one lifecycle controller and one set of active operations. Unload closes new selection/review admission, then aborts and drains reviews before removing listener and contribution. Only unload while the root context remains active changes live Auto Sessions to Full access through the existing preset writer, which writes the `never` approval policy without changing the sandbox value or closing terminals. Root shutdown preserves selection: stopping the application does not revoke its installed integration, and its child disposers can race Session persistence cleanup. The root fiber enters `UNLOADING` before child cleanup starts, so that lifecycle state distinguishes shutdown without a new shutdown service or timing heuristic. After provider settlement, a lifecycle abort always produces canonical pre-dispatch cancellation, including late allow, deny, or failure. Caller cancellation retains ToolRuntime's priority: late allow cancels before dispatch; a late failure or final denial retains that outcome, and a late denial under `ask` produces a cancelled approval and canonical pre-dispatch cancellation. No cancelled review starts a tool body. A persisted Auto Session cannot publish when the complete integration is absent or failed. Its log is not rewritten, and no background retry runs. Reinstallation allows a user to reopen it; live Sessions migrated to Full access remain there until explicitly switched. @@ -65,7 +65,7 @@ The [delegation-time policy capture](../../../../packages/subagent/subagent/READ ## Alternatives considered -**Default or released integration** would make an experimental model approval policy part of every deployment. A private source Web patch keeps opt-in explicit while using the existing plugin installer. +**Default or released integration** would make an experimental model approval policy part of every deployment. An explicit profile patch keeps opt-in explicit while using the existing plugin installer. **A new sandbox or approval-policy value** would couple review to enforcement and create combinations without a current consumer. An explicit preset identity preserves the unchanged Full access execution behavior. 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 bcea097f5e..5ba7a8b39c 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 @@ -10,7 +10,7 @@ Full access 让有用的项目工作无需反复审批即可继续,但也允 ## 决策 -[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.zh.md)是显式安装的实验性 Web 层,按[实验包发布策略](../../../../packages/experimental/README.zh.md)参与发布。默认 Web 保持 Read Only、Workspace Write 与 Full access。此层贡献仅限当前会话的 `auto`,唯一持久身份为 `permission/preset:auto`;它使用 Full access 的 `danger-full-access` 沙箱与工具定义,审批策略由[用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)决定。Headless、通用设置与新会话默认值都排除此 integration。 +[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.zh.md)是显式安装的实验性 profile 层,按[实验包发布策略](../../../../packages/experimental/README.zh.md)参与发布。默认 Web 保持 Read Only、Workspace Write 与 Full access。此层贡献仅限当前会话的 `auto`,唯一持久身份为 `permission/preset:auto`;它使用 Full access 的 `danger-full-access` 沙箱与工具定义,审批策略由[用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)决定。默认 Headless、通用设置与新会话默认值都排除此 integration。[Headless 调用者入口](2026-10-18-headless-auto-review.zh.md)定义显式 headless 启用方式与不依赖传输的 user-source 权威。 每个原生调用与已开始的 PTC `tools.*` inner call 都在 body 前接受一次审查。外层 `run_code` transport 与 PTC 程序内直接 Node 效果不在保证范围内。不提供按工具名豁免、缓存 grant、重试、可配置策略或第二授权检查;拒绝之后的行为由[用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)负责。重复调用也重新审查。 @@ -21,12 +21,12 @@ Full access 让有用的项目工作无需反复审批即可继续,但也允 | 风险 | 示例 | 决定 | | --- | --- | --- | | `low` | 普通项目内读写、分析、format/lint/test/build、非破坏性 Git,以及保留调用能证明在本 Session 创建的对象的精确清理 | 允许 | -| `medium` | 不可逆删除既有状态、force push/历史改写、生产读写/部署、非敏感外部写入/发送,以及权限/安全/系统变更 | 只有当前 human 或直接父级明确授权动作、准确目标与必要范围时才允许 | +| `medium` | 不可逆删除既有状态、force push/历史改写、生产读写/部署、非敏感外部写入/发送,以及权限/安全/系统变更 | 只有当前用户或直接父级明确授权动作、准确目标与必要范围时才允许 | | `high` | 敏感数据跨越当前信任边界外泄及等价 hard-deny 效果 | 拒绝,包括显式请求的动作 | -实际效果不明确或超出已确立范围时按拒绝处理。后续指令只有明确撤销或替换旧指令才能消除冲突;直接父级指令不能覆盖 human 限制。历史可以证明对象由本会话创建,但不能授权 medium 操作或解除 high 禁令。 +实际效果不明确或超出已确立范围时按拒绝处理。后续指令只有明确撤销或替换旧指令才能消除冲突;直接父级指令不能覆盖用户限制。历史可以证明对象由本会话创建,但不能授权 medium 操作或解除 high 禁令。 -Reviewer 从既有 Session 事实派生权威。Shipped Web human 指令具有 `source.kind === 'user'` 和浏览器 prompt admission 写入的 `rpcId`。Child 初始直接父级 prompt 在其自身既有创建 descriptor 之后识别;后续 `agent-message` 只有 `senderSessionId` 与 `SessionHeader.parentSession` 匹配时才属于直接父级指令。Human 文本定义或替换任务与限制。直接父级文本在这些限制内定义 child 任务。项目指令只施加约束;checkpoint 恢复有损语境;图片、附件元数据和历史调用提供事实。Compaction 不会把 checkpoint 提升为已经离开 surface 的原文本所拥有的 human 权威。 +Reviewer 从既有 Session 事实派生权威。`source.kind === 'user'` 的文本定义或替换任务与限制,不区分输入传输。Child 初始直接父级 prompt 在其自身既有创建 descriptor 之后识别;后续 `agent-message` 只有 `senderSessionId` 与 `SessionHeader.parentSession` 匹配时才属于直接父级指令。继承的 child 创建 prompt 与无法验证的 child 创建 prompt 仍是事实。[User-source 接纳](2026-10-18-headless-auto-review.zh.md)定义调用者与恢复历史的语义。直接父级文本在用户限制内定义 child 任务。项目指令只施加约束;checkpoint 恢复有损语境;图片、附件元数据和历史调用提供事实。Compaction 不会把 checkpoint 提升为已经离开 surface 的原文本所拥有的指令权威。 ### 一次完整 reviewer 请求 @@ -36,7 +36,7 @@ Integration 只使用最新 `request/header.config` 的 provider/model 与 shi | --- | --- | | System prompt `REVIEW_POLICY` | 固定分类、来源权威、消息布局与严格结果规则;allow 后立即以 Full access 执行,没有后续确认 | | 第一条 user 消息 `ENVIRONMENT` | 仅既有 Session header `cwd` | -| 每个保留条目一条 user 消息 | 当前 compaction surface 中带来源的 human/直接父级消息、带约束角色的项目指令、checkpoint、图片/附件事实、历史调用名称与日志参数,以及在首次使用某个已审 schema 的调用之前出现的 `tool-schema` 条目,按 surface 顺序排列 | +| 每个保留条目一条 user 消息 | 当前 compaction surface 中带来源的用户/直接父级消息、带约束角色的项目指令、checkpoint、图片/附件事实、历史调用名称与日志参数,以及在首次使用某个已审 schema 的调用之前出现的 `tool-schema` 条目,按 surface 顺序排列 | | 最后一条 user 消息 | 待审调用及其日志参数;其之前同 mode 与名称的最新 `tool-schema` 条目给出工具描述与参数 schema | 每个条目各占一条消息,并在后续评审中保持相同文本:待审调用会变成历史调用,integration 为每个存活 Session 记住每次已审调用展示的 schema,使其在同一位置再次出现。因此一次评审的消息以上一次评审的全部消息开头。只在消息结尾缓存的 provider(如 GPT-5.6 及以后的 OpenAI 隐式断点),或在末尾标记处向前回溯的 provider(如 Anthropic `cache_control`),都能复用上一次评审;单条不断增长的 user 消息只能让它们复用策略。Schema 记忆只在进程内,因此重启后的第一次评审会缺少先前的 schema,并有一次缓存未命中。 @@ -51,7 +51,7 @@ Reviewer 可以输出 reasoning blocks,随后恰好一个 JSON text block 和 最终拒绝([用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)将其限定在 `never` 审批策略下)让原生结果与 PTC 结算事件携带同形结构化 `AutoReviewDeniedError`/`AUTO_REVIEW_DENIED` 及可选原始理由。对最终拒绝,主 agent 通过普通失败渲染只收到 `Auto review rejected tool ""; its body was not executed`。PTC 保留既有程序异常/catch 行为;捕获拒绝不会将其提升为外层失败。通用 Web 工具卡片为折叠行提供拒绝身份,为展开行提供一行未执行输出,不提供输入正文。只有该显示过程会 trim、折叠行分隔符,或提供本地化空理由 fallback;持久化与两套 SDK 保留完整原始理由,不增加长度或脱敏规则。 -准入与在途 review 登记在首次 await 前同步完成。Integration 拥有一个生命周期 controller 和一个在途操作集合。卸载先关闭新选择/review admission,经由既有 preset writer 把存活 Auto Session 切为 Full access,该 writer 写入 `never` 审批策略,不改变沙箱值、不关闭终端,然后中止并等待 review 结清,最后移除 listener 与 contribution。Provider 结算后,lifecycle abort 始终形成规范的 dispatch 前取消,包括晚到 allow、deny 或 failure。Caller 取消保留 ToolRuntime 优先级:晚到 allow 在 dispatch 前取消;晚到 failure 或最终拒绝保留原结果,`ask` 下晚到的拒绝产生已取消的审批与规范的 dispatch 前取消。被取消的 review 不启动工具 body。 +准入与在途 review 登记在首次 await 前同步完成。Integration 拥有一个生命周期 controller 和一个在途操作集合。卸载先关闭新选择/review admission,然后中止并等待 review 结清,最后移除 listener 与 contribution。只有在根 context 仍活跃时卸载插件,才经由既有 preset writer 把存活 Auto Session 切为 Full access;该 writer 写入 `never` 审批策略,不改变沙箱值、不关闭终端。根关闭保留选择:停止应用不等于撤销已安装的 integration,且子级 disposer 可能与 Session 持久化清理并发。根 fiber 在子级清理开始前进入 `UNLOADING`,因此可用该生命周期状态区分关闭,无需新建关闭服务或依赖时序推测。Provider 结算后,lifecycle abort 始终形成规范的 dispatch 前取消,包括晚到 allow、deny 或 failure。Caller 取消保留 ToolRuntime 优先级:晚到 allow 在 dispatch 前取消;晚到 failure 或最终拒绝保留原结果,`ask` 下晚到的拒绝产生已取消的审批与规范的 dispatch 前取消。被取消的 review 不启动工具 body。 完整 integration 缺失或失败时,持久 Auto Session 不能发布。日志不改写,也不后台重试。重装后用户可以重新打开它;已经迁移为 Full access 的存活 Session 保持原状,直到显式切换。 @@ -65,7 +65,7 @@ Auto 在选择器选项中带 `EXP` 标记;选中后的 composer 控件不显 ## 考虑过的替代方案 -**默认或正式发布 integration** 会把实验模型审批策略带入每个部署。私有源码 Web patch 在复用既有插件安装器的同时保持显式 opt-in。 +**默认或正式发布 integration** 会把实验模型审批策略带入每个部署。显式 profile patch 在复用既有插件安装器的同时保持显式 opt-in。 **新增 sandbox 或 approval-policy 值**会把 review 与执行耦合,产生没有当前消费者的组合。显式 preset 身份保留 Full access 未改变的执行行为。 diff --git a/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.i18n.yaml b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.i18n.yaml index 9ab5b64bd8..742b83fa8e 100644 --- a/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.i18n.yaml @@ -9,8 +9,8 @@ en: ed725dff16cd2339 zh: 555d4ba70af96790 /agent-note-auto-review-asks-the-user-after-a-denial/decision: - en: f5ec55bcfe5e9a9a - zh: 1b7462f8f06320e9 + en: 6bd32636c775b690 + zh: 5579111a3dbb6bf9 /agent-note-auto-review-asks-the-user-after-a-denial/alternatives-considered: en: 01f71dd10849f925 zh: 8b79a7e149a9a60c diff --git a/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.md b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.md index 73f8e6f3b1..77692a1d1e 100644 --- a/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.md +++ b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.md @@ -14,7 +14,7 @@ A reviewer denial returns the tools pipeline's existing `ask` decision when the Selecting Auto writes `danger-full-access` with the `ask` approval policy. A recorded Auto selection also matches the `never` policy. A [delegated in-process child pins `never`](../../../../packages/subagent/subagent/README.md), so a child Auto keeps the final `AutoReviewDeniedError` denial and its reason, and a Session recorded under the earlier `never` bundle still resolves to Auto instead of Full access. Selecting Auto again writes `ask`. -A reviewer failure denies the call with `Auto review of tool "" failed; its body was not executed: ` and no structured error info. A provider failure's message carries the finish kind, failure code, and provider message, so the model and the generic tool card show the actual cause. +A reviewer failure denies the call with `Auto review of tool "" failed; its body was not executed: ` and structured `AutoReviewFailedError` / `AUTO_REVIEW_FAILED` info. [Headless callers](2026-10-18-headless-auto-review.md) use that identity to distinguish failure from denial. A provider failure's message carries the finish kind, failure code, and provider message, so the model and the generic tool card show the actual cause. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.zh.md b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.zh.md index a215f09e3d..811cd7f67c 100644 --- a/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.zh.md +++ b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.zh.md @@ -14,7 +14,7 @@ Status: implemented 选择 Auto 会写入 `danger-full-access` 与 `ask` 审批策略。已记录的 Auto 选择也匹配 `never` 策略。[进程内委派 child 固定 `never`](../../../../packages/subagent/subagent/README.zh.md),因此 child 的 Auto 保留最终的 `AutoReviewDeniedError` 拒绝及其理由;按先前 `never` 组合记录的 Session 仍解析为 Auto,而不是 Full access。再次选择 Auto 会写入 `ask`。 -Reviewer 失败以 `Auto review of tool "" failed; its body was not executed: ` 拒绝调用,不带结构化错误信息。Provider 失败消息包含 finish 类型、失败代码与 provider 消息,因此模型与通用工具卡片显示实际原因。 +Reviewer 失败以 `Auto review of tool "" failed; its body was not executed: ` 拒绝调用,带结构化 `AutoReviewFailedError` / `AUTO_REVIEW_FAILED` 信息。[Headless 调用者](2026-10-18-headless-auto-review.zh.md)用该身份区分失败与拒绝。Provider 失败消息包含 finish 类型、失败代码与 provider 消息,因此模型与通用工具卡片显示实际原因。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.i18n.yaml b/.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.i18n.yaml new file mode 100644 index 0000000000..4abceb8733 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-02-plugin-market-discover.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/feature/2026-10-02-plugin-market-discover.md +/agent-note-plugin-market-discover-page-and-index-reader: + en: e226087ae345768b + zh: b043fa6c793fd684 +/agent-note-plugin-market-discover-page-and-index-reader/problem: + en: 329c6ff947559b11 + zh: 5c4c1053236d319b +/agent-note-plugin-market-discover-page-and-index-reader/decision: + en: cc4d17a51dd479b7 + zh: 1fecf669b117f51c +/agent-note-plugin-market-discover-page-and-index-reader/alternatives-considered: + en: 62e7691dc8efd5c0 + zh: b13769634979b950 +/agent-note-plugin-market-discover-page-and-index-reader/consequences: + en: 672397a936cae42f + zh: 1f535708b914a915 diff --git a/.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md b/.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md new file mode 100644 index 0000000000..505ea944e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md @@ -0,0 +1,56 @@ +# Agent Note: Plugin market Discover page and index reader + +Status: implemented + +English | [中文](2026-10-02-plugin-market-discover.zh.md) + +## Problem + +The [plugin market index format](2026-10-02-plugin-market-index-format.md) lets a market publish installable bundles in one document, but DSH needs a reader that fetches indexes safely, a place where users choose index sources and browse entries, and a path from an entry to the existing installation flow. No official market exists, so DSH cannot ship a default source; the sources are user data. + +The plugin manager owns profile package operations and must not remount while an installation runs. Market configuration that a user edits from the Web therefore cannot be an ordinary configuration field of `plugin-manager`. + +## Decision + +The feature ships as the experimental optional [custom plugin markets bundle](../../../../packages/experimental/plugin-market-bundle/README.md), off by default, which mounts both rows below; with it off, neither the service, the Discover tab, nor the `plugin_market` tool exists, and the Plugins page shows no tabs. A separate Host service, [`pluginMarket`](../../../../packages/boot/plugin-market/README.md), reads indexes. It requires `pluginManager` for read-only calls and never writes the profile. Its `indexes` configuration field is volatile: Settings writes only volatile fields, and the Loader commits a volatile-only change into the running reference without remounting any plugin. Other fields (`fetchTimeoutMs`, `maxBytes`, `cacheTtlMs`) are ordinary configuration. + +Index URLs use `http:` or `https:`, without credentials or fragment. `file:` is excluded because relative icon references would resolve to URLs a browser cannot load. `search` and `entry` accept only a configured URL, so the Host never fetches an address a client supplies. The Host fetches through the process proxy, refuses redirects, bounds the body, and validates with the shared format package; an invalid index affects only its own URL. + +Indexes can reach the default 128 MiB `maxBytes`, so clients never receive or filter a whole index. `search` filters, matches text, orders, and pages on the Host and returns at most 100 entries; `entry` returns one plugin by name for the tool. Text matching is fuzzy and covers the package name, author, keywords, and every language of the title, description, and category and label titles, because a person may search in any language a market writes. A Chinese, Japanese, or Korean run is indexed as overlapping character pairs and its last character, so queries match without a word dictionary. [MiniSearch](https://github.com/lucaong/minisearch) builds the text index on the first text query of each document and yields between chunks. Each page carries its document's `fetchedAt`, and a later page sent with it is answered from that document while it is cached, so paging stays consistent across cache expiry; a client whose document was replaced starts again from the first page. + +Each returned entry carries the profile's installation status, its compatibility computed by `evaluatePluginCompatibility` with the profile's version exemptions, and `install`, the exact arguments from `marketInstallTarget`. That is the only place install arguments are built; the Web page and the model tool consume it. + +The Web [Discover tab](../../../../packages/client/ui-plugin-market/README.md) is its own client plugin. [`ui-plugin-manager`](../../../../packages/client/ui-plugin-manager/README.md) declares a `plugins.tab` slot and shows **Installed** and each contributed tab under the page header, because browsing a market and managing installed bundles are separate tasks of equal rank. The owner props provide `install(spec, registry?)` for a spec the person confirmed in the tab, which opens the existing install dialog at its check screen without the spec form, and `openPackage(name)`, which opens an installed bundle's page. A registry an entry names applies to that install only and is never remembered as the dialog's choice, so one market cannot redirect later manual installs. Discover confirms each install with the entry's origin and a trust notice, and keeps source management, which adds a code origin, in a dialog with its own trust notice. Each entry has a page with its full description, source facts, and every listed version; **Install** on a card chooses the default version, and the page can install any other version installation accepts. Indexes are browsed one at a time; categories, labels, and sort keys stay opaque and display in localized title order; entries missing the selected sort value come last in both directions. + +A read-only `plugin_market` tool lists indexes and pages entries, returning the `target` and `registry` that `plugin_manager`'s `install_bundle` accepts. It requires no approval; installation keeps `plugin_manager`'s approval. The tool cannot change index sources, because adding a source introduces a new code origin. + +## Alternatives considered + +**Market fields on `plugin-manager`.** A non-volatile field would remount `plugin-manager` when the Web edits it and interrupt package operations it owns. Even with a volatile field, a separate service guarantees that market configuration never reaches `plugin-manager`, at the cost of one more package and service. + +**Configuring indexes only in `cordis.yml`.** Most users do not edit `cordis.yml`, so Discover would stay empty for them. + +**Fetching indexes in the browser.** CORS, the Host's proxy configuration, and validation where untrusted data enters favor Host fetches. + +**Filtering in the browser.** It needs the whole annotated index in every client, which grows with the index and recomputes compatibility for every entry on each read. + +**A query protocol on the index host.** It would keep large indexes off the Host but requires a server for every market, which a static index file avoids. It remains the next step if indexes outgrow the Host. + +**Merging all configured indexes into one list.** Opaque ids from different markets may collide with different meanings, and duplicate package names need a conflict rule. Per-index browsing avoids both. + +**Rendering Discover inside `ui-plugin-manager`.** The page would depend on the market Remote and grow further; a slot keeps the page usable without the market and lets the market plugin own its copy and state. + +**Discover as a section under the Installed groups, installing through the spec form.** Finding it requires scrolling past every installed bundle, and the form asks a person to review a spec they never typed. Tabs give browsing its own entry point, and a confirmation with the entry's origin followed by the check screen replaces the form. + +**An approval for `plugin_market`.** Listing changes nothing and reads only sources the user configured; requiring approval would add a prompt without protecting an operation. The installation step keeps its approval. + +**An upgrade action.** Upgrading changes the documented "uninstall, then install" behavior and is deferred; Discover shows `update-available` as a status. + +## Consequences + +- Users browse and install third-party bundles without knowing a spec, and the existing install dialog, registry fallback, build-script approval, and compatibility refusal apply unchanged. +- The Settings form also shows `indexes`, because Settings lists every volatile field. +- An `http:` index, including the install specs it lists, can be altered in transit; a market reached over an untrusted network needs `https:`. +- URL icons are requested by the browser and disclose the user's IP address to image hosts; Discover loads them without a referrer. +- Market text reaches users and, through `plugin_market`, models; the tool description marks it as third-party content. +- A real-composition test proves that `setIndexes` persists through the profile without remounting the service, and unit tests cover fetch limits, cache sharing, removal, and search matching, ordering, and paging. diff --git a/.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.zh.md b/.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.zh.md new file mode 100644 index 0000000000..f924fb0641 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.zh.md @@ -0,0 +1,56 @@ +# Agent Note: 插件市场发现页与索引读取器 + +Status: implemented + +[English](2026-10-02-plugin-market-discover.md) | 中文 + +## 问题 + +[插件市场索引格式](2026-10-02-plugin-market-index-format.zh.md)让市场能在一份文档中发布可安装的组合包(bundle),但 DSH 还需要:一个安全获取索引的读取器,一个让用户选择索引源并浏览条目的位置,以及一条从条目进入现有安装流程的路径。目前没有官方市场,因此 DSH 无法随附默认源;索引源属于用户数据。 + +插件管理器拥有 profile 的包操作,安装运行期间不得重新挂载。因此,用户从 Web 编辑的市场配置不能是 `plugin-manager` 的普通配置字段。 + +## 决策 + +该功能以实验性的可选[自定义插件市场组合包](../../../../packages/experimental/plugin-market-bundle/README.zh.md)发布,默认关闭,并挂载下面两行;关闭时,服务、「发现」标签页和 `plugin_market` 工具都不存在,插件页也不显示标签页。由独立的 Host 服务 [`pluginMarket`](../../../../packages/boot/plugin-market/README.zh.md) 读取索引。它依赖 `pluginManager` 进行只读调用,从不写入 profile。它的 `indexes` 配置字段是 volatile 字段:Settings 只写入 volatile 字段,Loader 把只涉及 volatile 字段的变更提交到运行中的引用,不重新挂载任何插件。其他字段(`fetchTimeoutMs`、`maxBytes`、`cacheTtlMs`)是普通配置。 + +索引 URL 使用 `http:` 或 `https:`,不含凭据或片段。排除 `file:`,因为相对图标引用会解析为浏览器无法加载的 URL。`search` 和 `entry` 只接受已配置的 URL,因此 Host 从不获取客户端提供的地址。Host 通过进程代理获取,拒绝重定向,限制响应体大小,并用共享的格式包校验;无效索引只影响它自己的 URL。 + +索引可能达到默认 128 MiB 的 `maxBytes`,因此客户端从不接收或过滤整个索引。`search` 在 Host 上完成过滤、文本匹配、排序和分页,最多返回 100 个条目;`entry` 按名称为工具返回一个插件。文本匹配是模糊的,覆盖包名、作者、关键词,以及标题、描述、分类标题和标签标题的所有语言,因为用户可能使用市场所写的任意语言搜索。中文、日文或韩文片段被索引为相邻的两字组合及其最后一个字符,因此查询无需分词词典即可匹配。[MiniSearch](https://github.com/lucaong/minisearch) 在每份文档第一次带文本的查询时构建文本索引,并在分块之间让出事件循环。每页都携带其文档的 `fetchedAt`,之后带着它发送的页请求只要该文档仍在缓存中就用它回答,因此分页在缓存过期后仍保持一致;文档被替换的客户端从第一页重新开始。 + +每个返回的条目携带 profile 中的安装状态、由 `evaluatePluginCompatibility` 结合 profile 版本豁免计算出的兼容性,以及 `install`,即 `marketInstallTarget` 给出的精确参数。这是唯一构建安装参数的位置;Web 页面和模型工具都使用它。 + +Web 的[发现标签页](../../../../packages/client/ui-plugin-market/README.zh.md)是独立的 Client 插件。[`ui-plugin-manager`](../../../../packages/client/ui-plugin-manager/README.zh.md) 声明 `plugins.tab` slot,并在页头下方显示**已安装**和每个贡献的标签页,因为浏览市场与管理已安装组合包是两项同等重要的独立任务。owner props 提供 `install(spec, registry?)` 和 `openPackage(name)`:前者用于用户已在标签页中确认的 spec,在检查界面打开现有的安装对话框,不显示 spec 表单;后者打开已安装组合包的页面。条目指定的注册表只用于这一次安装,从不作为对话框的选择被记住,因此一个市场无法改变之后手动安装所用的注册表。发现标签页在每次安装前显示条目来源和信任提示以供确认,并把会引入代码来源的索引源管理放在带有自身信任提示的对话框中。每个条目都有一个页面,显示其完整描述、来源信息和列出的每个版本;卡片上的**安装**选择默认版本,页面可以安装安装检查接受的任何其他版本。索引一次浏览一个;分类、标签和排序键保持不透明,按本地化标题顺序显示;缺少所选排序值的条目在两个方向上都排在最后。 + +只读的 `plugin_market` 工具列出索引并分页列出条目,返回 `plugin_manager` 的 `install_bundle` 接受的 `target` 和 `registry`。它不需要批准;安装仍保留 `plugin_manager` 的批准。该工具不能修改索引源,因为添加索引源会引入新的代码来源。 + +## 考虑过的替代方案 + +**把市场字段放在 `plugin-manager` 上。** 非 volatile 字段在 Web 编辑它时会重新挂载 `plugin-manager`,中断它拥有的包操作。即使使用 volatile 字段,独立服务也能保证市场配置永远不会到达 `plugin-manager`,代价是多一个包和服务。 + +**只在 `cordis.yml` 中配置索引。** 大多数用户不编辑 `cordis.yml`,发现区对他们会一直为空。 + +**在浏览器中获取索引。** CORS、Host 的代理配置,以及在不可信数据进入处进行校验,都支持由 Host 获取。 + +**在浏览器中过滤。** 这需要每个客户端都拿到整个带注解的索引,其大小随索引增长,并且每次读取都要为每个条目重新计算兼容性。 + +**在索引主机上提供查询协议。** 它能让大索引不进入 Host,但每个市场都需要一台服务器,而静态索引文件不需要。如果索引超出 Host 的承受能力,这是下一步。 + +**把所有已配置索引合并成一个列表。** 不同市场的不透明 id 可能相同但含义不同,重复的包名也需要冲突规则。按索引分别浏览可以避免这两个问题。 + +**在 `ui-plugin-manager` 内渲染发现区。** 该页面会依赖市场 Remote,代码量也会继续增加;使用 slot 时,没有市场该页面仍可使用,市场插件也能拥有自己的文案和状态。 + +**把发现作为「已安装」分组下方的区域,并通过 spec 表单安装。** 找到它需要滚动经过每个已安装组合包,而且该表单要求用户审核一个自己从未输入的 spec。标签页为浏览提供独立入口,先显示条目来源以供确认、再进入检查界面的流程取代了该表单。 + +**为 `plugin_market` 要求批准。** 列出操作不做任何改动,只读取用户配置的源;要求批准只会增加一次提示,而不保护任何操作。安装步骤保留其批准。 + +**升级操作。** 升级会改变已记录的「先卸载再安装」行为,因此延后;发现区把 `update-available` 显示为状态。 + +## 后果 + +- 用户无需知道 spec 即可浏览并安装第三方组合包,现有的安装对话框、registry 回退、构建脚本批准和兼容性拒绝照常适用。 +- 设置表单也显示 `indexes`,因为设置会列出每个 volatile 字段。 +- `http:` 索引(包括其中列出的安装 spec)可能在传输途中被篡改;经由不可信网络访问的市场需要使用 `https:`。 +- URL 图标由浏览器请求,会向图片主机暴露用户的 IP 地址;发现区加载图标时不发送 referrer。 +- 市场文本会到达用户,并通过 `plugin_market` 到达模型;工具描述将其标记为第三方内容。 +- 一个真实组合测试证明 `setIndexes` 会经由 profile 持久化而不重新挂载服务,单元测试覆盖获取限制、缓存共享、移除,以及搜索的匹配、排序和分页。 diff --git a/.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.i18n.yaml b/.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.i18n.yaml new file mode 100644 index 0000000000..425a7295e2 --- /dev/null +++ b/.agents/notes/implemented/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/implemented/feature/2026-10-02-plugin-market-index-format.md +/agent-note-plugin-market-index-format: + en: ee3515adb22d4af6 + zh: 5dfd687255f64e95 +/agent-note-plugin-market-index-format/problem: + en: ce5e4a362f558af6 + zh: 29d93e0655b99217 +/agent-note-plugin-market-index-format/decision: + en: 906ac925fc53dd87 + zh: affcc405e186b0d7 +/agent-note-plugin-market-index-format/decision/example: + en: 1b1c960c966cf912 + zh: 97be87cb14893c26 +/agent-note-plugin-market-index-format/decision/root-fields: + en: 4fac5a9b8261342d + zh: f9168a8a4209feab +/agent-note-plugin-market-index-format/decision/plugin-fields: + en: b79abd05a957948b + zh: b6f0c203ea8e4838 +/agent-note-plugin-market-index-format/decision/version-fields: + en: d6c0fa99e1cbb068 + zh: 3d2a7b85299c679a +/agent-note-plugin-market-index-format/decision/install-arguments: + en: c748cdc4d8ce349a + zh: 5740fa0d6106e637 +/agent-note-plugin-market-index-format/decision/dsh-compatibility: + en: 00fe507b9b5dfea7 + zh: 55de6aaeb3865e84 +/agent-note-plugin-market-index-format/decision/generator-obligations: + en: 923849223cb1e70e + zh: f3daf398bc06c81a +/agent-note-plugin-market-index-format/decision/reader-rules: + en: 92903cbe87d9c0ca + zh: 54af6cb33e9da252 +/agent-note-plugin-market-index-format/alternatives-considered: + en: 908407ed974101d2 + zh: b4b7391c513bda60 +/agent-note-plugin-market-index-format/consequences: + en: 4aee5ff31901049a + zh: 8187b6216bb8b06b +/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/implemented/feature/2026-10-02-plugin-market-index-format.md similarity index 90% rename from .agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.md rename to .agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md index 6ab05ec875..cb772e894b 100644 --- a/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.md +++ b/.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md @@ -1,6 +1,6 @@ # Agent Note: Plugin market index format -Status: proposed +Status: implemented English | [中文](2026-10-02-plugin-market-index-format.zh.md) @@ -12,9 +12,9 @@ The smallest possible index is a list of npm names or Git repository URLs, one p 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 +## Decision -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 **index** is separate 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-plugin-market-index`](../../../../packages/util/plugin-market-index/README.md) is the executable rule set; the [Discover Agent Note](2026-10-02-plugin-market-discover.md) records how DSH reads and presents indexes. 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. @@ -45,6 +45,7 @@ The market reuses npm's package names, versions, ranges, distribution tags, and "icon": "icons/auto-review.svg", "author": "Example Labs", "homepage": "https://example.com/dsh-auto-review", + "repository": { "url": "https://github.com/example/dsh-plugins", "directory": "auto-review" }, "metadata": { "category": "productivity", "labels": ["official"], @@ -114,9 +115,10 @@ Category, label, and sort-key ids are non-empty strings. Readers look ids up as | `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. | +| `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 32 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. | +| `repository` | no | `{ "url": string, "directory"?: string }`. `url` is the absolute `https:` or `http:` URL of the source repository's web page, without credentials, query, or fragment; `directory` is the package directory inside it, with the `path` rule of a Git source. It is for display and never selects an install source. | | `metadata` | no | Object holding the values below. | | `metadata.category` | no | One declared category id. | | `metadata.labels` | no | Array of distinct declared label ids. | @@ -166,7 +168,7 @@ A reader cannot verify these rules; a generator that violates them publishes a m - `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. +- `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. `repository` comes from that version's manifest `repository`: a shorthand or Git URL becomes the repository's web URL, `directory` is copied when it meets the `directory` rule and otherwise dropped while `url` stays, and the field is absent when the manifest has none or names no web-addressable repository. - `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 @@ -197,7 +199,7 @@ The initial channel is `latest`. Its exact target is the publisher- or market-se **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. +**Inline icons only.** A list without extra requests needs inline icons, but the 256 KiB installed-plugin icon limit 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. The 32 KiB inline limit is about 43 KiB as base64, so 1,000 plugins with inline icons add at most about 43 MB; larger icons use URL references. **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. @@ -215,7 +217,7 @@ The initial channel is `latest`. Its exact target is the publisher- or market-se **Rejecting unknown members.** Every optional addition would then require a new `format` value and break every deployed reader. -## Acceptance criteria +## Consequences - 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. diff --git a/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.zh.md b/.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.zh.md similarity index 90% rename from .agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.zh.md rename to .agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.zh.md index 12608c767b..1d76f9d462 100644 --- a/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.zh.md +++ b/.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.zh.md @@ -1,6 +1,6 @@ # Agent Note: 插件市场索引格式 -Status: proposed +Status: implemented [English](2026-10-02-plugin-market-index-format.md) | 中文 @@ -12,9 +12,9 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包 因此,索引必须包含列表页面显示的全部内容:本地化标题和描述、图标、作者、可用版本、兼容性和发布时间。市场还需要发布自己的数据,例如分类、标签、下载量和评分,而 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)安装它们。 +**索引**与**索引生成器**相互分离。DSH 定义并读取一种 JSON 索引格式。索引如何生成、基于哪份源列表生成,由生成器负责。[`dsh-plugin-market-index`](../../../../packages/util/plugin-market-index/README.zh.md) 是可执行的规则集;[发现 Agent Note](2026-10-02-plugin-market-discover.zh.md) 记录 DSH 如何读取和展示索引。索引列出[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 无法满足的插件市场需求。 @@ -45,6 +45,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包 "icon": "icons/auto-review.svg", "author": "Example Labs", "homepage": "https://example.com/dsh-auto-review", + "repository": { "url": "https://github.com/example/dsh-plugins", "directory": "auto-review" }, "metadata": { "category": "productivity", "labels": ["official"], @@ -114,9 +115,10 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包 | `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 同源。 | +| `icon` | 否 | 以下两种形式之一:`data:;base64,`,其中 `` 为 `image/svg+xml`、`image/png`、`image/jpeg` 或 `image/webp`,`` 是按规范填充的 base64,解码后的载荷不超过 32 KiB;或者 URL 引用,它相对于索引 URL 解析的结果是 `https:` URL,或与索引 URL 同源。 | | `author` | 否 | 至少包含一个非空白字符的字符串。 | | `homepage` | 否 | 介绍该插件的页面的绝对 `https:` 或 `http:` URL。 | +| `repository` | 否 | `{ "url": string, "directory"?: string }`。`url` 是源代码仓库网页的绝对 `https:` 或 `http:` URL,不含凭据、查询或片段;`directory` 是包在仓库中的目录,规则与 Git 来源的 `path` 相同。该字段仅用于展示,从不决定安装来源。 | | `metadata` | 否 | 包含下列值的对象。 | | `metadata.category` | 否 | 一个已声明的分类 id。 | | `metadata.labels` | 否 | 由互不相同的已声明标签 id 组成的数组。 | @@ -166,7 +168,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包 - `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 没有该字段时省略。 +- `title`、`description` 和 `icon` 来自所列最高版本的语言元数据、manifest 字段和图标,采用已安装插件的显示规则。使用 npm 的 `node-semver.rsort()` 顺序,相等时保留输入顺序。元数据选择不选择安装版本。`homepage` 复制该版本 manifest 的 `homepage`,manifest 没有该字段时省略。`repository` 来自该版本 manifest 的 `repository`:简写或 Git URL 转换为仓库的网页 URL,`directory` 符合 `directory` 规则时原样复制,否则丢弃并保留 `url`;manifest 没有该字段,或其指向的仓库没有网页地址时省略。 - `author` 来自同一最高版本的 npm 作者声明。复制对象的 `name`;使用维护中的 npm person 归一化器解析简写字符串。省略缺失或全空白的名称以及联系字段。归一化新建的投影,不修改源 manifest。 ### 读取器规则 @@ -197,7 +199,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包 **每个版本各有显示字段。** 列表每个插件只显示一行,旧的显示文本、图标、作者和主页会增加索引大小,却没有读取器显示它们。 -**只允许内联图标。** 不发额外请求的列表需要内联图标,但 256 KiB 原始字节编码为 base64 后约为 341 KiB,因此 100 个插件的索引可能达到约 35 MB。URL 图标以额外请求和向图片主机暴露用户 IP 地址为代价,换取较小的索引。 +**只允许内联图标。** 不发额外请求的列表需要内联图标,但已安装插件的 256 KiB 图标上限编码为 base64 后约为 341 KiB,因此 100 个插件的索引可能达到约 35 MB。URL 图标以额外请求和向图片主机暴露用户 IP 地址为代价,换取较小的索引。32 KiB 的内联上限编码为 base64 后约为 43 KiB,因此 1,000 个带内联图标的插件最多增加约 43 MB;更大的图标使用 URL 引用。 **从对等依赖推断宿主兼容性。** 市场定义要求作者声明 `engines.dsh` 的新标准。现有包必须添加此声明才能列出;不需要回退、workspace 映射或范围求交。 @@ -215,7 +217,7 @@ DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包 **拒绝未知成员。** 这样每次增加可选成员都需要新的 `format` 值,并会破坏所有已部署的读取器。 -## 验收标准 +## 后果 - 校验器接受上面的示例,并对根字段、插件字段、版本字段和 `source` 形式中的每条规则各拒绝一个违例,诊断信息给出对应的 JSON 路径。 - 根据每个示例版本构建的安装参数可被 DSH 的安装 spec 解析器和 pnpm 接受。 diff --git a/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.i18n.yaml b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.i18n.yaml new file mode 100644 index 0000000000..4124137839 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for 2026-10-14-host-release-change-refresh-prompt.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/feature/2026-10-14-host-release-change-refresh-prompt.md +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases: + en: 559df9281e9994e0 + zh: c51f2b1f9973127f +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/problem: + en: d98f5657dd30d9da + zh: 19d1210e57eb663b +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision: + en: d39be9788fe2a0c9 + zh: 1a68a3ae4d73930d +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/behavior: + en: f85ebe23d677ae93 + zh: a829e0692e711660 +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/release-identity: + en: e7cae8876e688f92 + zh: 1d326330c34d128d +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/wire: + en: f15c4106f228f5f6 + zh: ab80cf4ce62ae84c +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/page-side: + en: c0610c8f5a0882f3 + zh: d309cbc53183ec6e +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/ui: + en: 41b5a3346a7edffa + zh: a05ed84017556760 +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/alternatives-considered: + en: abb9d7ab9ef1df15 + zh: 1704732daf5f2886 +/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/consequences: + en: 815b41dc4f9ffcee + zh: b57388264cc459c7 diff --git a/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.md b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.md new file mode 100644 index 0000000000..33ac74d954 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.md @@ -0,0 +1,143 @@ +# Agent Note: Prompt to reload when the Host release changes instead of hot-replacing across releases + +Status: implemented + +English | [中文](2026-10-14-host-release-change-refresh-prompt.zh.md) + +## Problem + +After `dsh web` is upgraded, or after a developer restarts `pnpm run dev:web` at another commit, an open browser page reconnects to the new Host automatically while its static shell stays at the old release. When the protocol is no longer compatible, the page keeps failing to reconnect or shows errors, and the user is not told that a reload is required. + +Technical causes: + +- After a reconnect, [client-hmr](../../../../packages/client/hmr/src/client/index.ts) receives the new Host's complete graph, and [ClientEntries](../../../../packages/client/modules/src/client/entries.ts) hot-replaces plugins by each row's `rev` (the bundle's mtime, ctime, and size). The Vite shell, static libraries, and the bootstrap are never replaced during the page's lifetime. +- The API connection validates its ready frame against an exact key set ([remote-events.ts](../../../../packages/api/gateway/src/client/remote-events.ts)), so an old page cannot recover from a protocol change by itself. +- The page has no way to learn that the Host now runs another release. +- Desktop is unaffected: after its Host is replaced, the main process navigates the window again ([main.ts](../../../../apps/desktop/src/main.ts)). + +The [client plugin loading model](../architecture/2026-07-23-client-plugin-loading-model.md) owns the loading chain and hot replacement. It states that static libraries and the shell require a reloaded page; this note owns how the page learns that the Host's release changed and what it does about it. + +## Decision + +A release is the Host's version identity: a version plus an optional source commit. The name deliberately differs from "build", which names the build process and its artifacts. + +The change touches three owners: client-modules (producing, publishing, and comparing the release identity), the shared client build preset (fixing the commit into the Host artifact), and ui-settings-general (the confirmation dialog). web-app and client-hmr are unchanged. + +### Behavior + +A page applies Host graphs and rebuilt frames only while the connected Host serves the release the page booted from. Every module the page runs therefore belongs to its boot release: either the boot artifacts unchanged, or the boot artifacts plus hot replacements published by a Host of that same release. + +| Situation | Behavior | +|---|---| +| The Host serves the boot release; its graph equals the applied graph | Nothing happens. | +| The Host serves the boot release; some module revisions differ | Hot replacement of exactly the changed modules, as before; a shown dialog closes. | +| The Host serves another release (version or commit differs) | Nothing from it is applied, rebuilt frames are ignored, and a dialog shows the Host's and the page's transition. | +| The user chooses **Reload to load the new version** | `location.reload()` boots the page from the Host's release. | +| The user chooses **Not now**, the close control, a mask click, or Escape | The dialog closes; the page stays at its boot release and applies nothing from that Host release; later graphs of that release no longer ask. | +| The Host returns to the boot release | The dialog closes and the usual revision comparison replaces exactly the changed modules; unchanged modules stay. | +| The Host moves to a release other than the dismissed one | The dialog appears again. | + +The page remembers only the last dismissed Host release, in page memory; nothing is written to localStorage or IndexedDB. After a reload, the new boot release is the comparison basis. + +### Release identity + +- Fields: `{ version, commit? }`; two releases are the same only when both fields are equal. Every complete build, released builds included, records the repository commit, so pages compare both fields; a release changes both together. +- `version` is client-modules' own package.json version. All packages release under one version, so it equals the CLI version. +- `commit` is fixed at build time. The shared client build preset [tsdown.client.ts](../../../../packages/client/tsdown.client.ts) injects the same public `process.env.DSH_CLIENT_*` values into the Node half of every client package that it injects into the browser half ([`clientBuildValueDefines`](../../../../scripts/client-build-environment.ts) replaces the named variables and leaves `process.env` itself in place). The client-modules Host code reads `process.env.DSH_CLIENT_COMMIT_HASH`, which is a constant in the built artifact. +- A Host started from TypeScript source has no build step: `pnpm run dev:web` places the build values in the Host process environment, so the Host reads the same commit; a source launch without dev-web publishes the version alone. +- Field values are wire data that the dialog renders verbatim, without localization. + +### Wire + +``` +WebBootRelease { version: string; commit?: string } +WebBootGraph { + rev; entries; batches // unchanged + /** Host release identity; absent from a Host that publishes none. */ + release?: WebBootRelease +} +BootManifest { ...; release?: WebBootRelease } +``` + +- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) computes the release once at construction and `compose()` writes it into every graph; `release` stays outside `graph.rev`. There is no new config field and the composition is unchanged. +- `__DSH_BOOT__` and every SSE `graph` frame carry the field, so one graph frame carries both the Host release and its complete module graph; the client-hmr Host half is unchanged. A rebuilt frame comes from the same Host process as the graph frame before it on that SSE connection, so it inherits that frame's release. +- `parseBootManifest` accepts an absent `release`, rejects a non-string `version` or `commit`, and drops members outside the identity, so equality compares only those two fields. `readRelease` reads only the `release` member of a received graph, so a page compares releases before it validates anything else; a release it cannot read counts as absent. Older pages that do not know the field ignore it. + +### Page side + +The release comparison lives in [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts); `ClientEntries` consults it from `sync` and `reload`. + +``` +interface ClientReleaseChange { + /** Release this page booted from; every module the page runs belongs to it. */ + readonly page: WebBootRelease | undefined + /** Release the Host served before its latest release change. */ + readonly previous: WebBootRelease | undefined + /** Release of the connected Host. */ + readonly host: WebBootRelease | undefined +} + +class ReleaseChangeGate { + /** Release change to announce; undefined while the Host serves the boot release or a dismissed one. */ + readonly change: ObservableSnapshot + /** Whether the connected Host serves the boot release; graphs and rebuilt frames apply only then. */ + get matches(): boolean + /** Record a received graph's release and update the announcement; true when the graph may be applied. */ + observe(release: WebBootRelease | undefined): boolean + /** Stop announcing the connected Host's release. */ + dismiss(): void +} + +class ClientEntries { + /** The gate's change. */ + readonly releaseChange: ObservableSnapshot + /** Stop announcing the connected Host's release; updates stay paused until the Host returns or the page reloads. */ + dismissRelease(): void +} +``` + +Order of operations: + +1. `ClientEntries` creates the gate from the boot manifest's `release`. +2. `sync(graph)` reads the graph's release with `readRelease` and passes it to `observe` before parsing the rest, so a graph of another release is announced even when this page cannot read its format. A graph of the boot release is parsed and reconciles as before, and the existing revision comparison replaces exactly the changed modules. A graph of another release returns without entering the reconciliation queue; its first appearance logs the Host's previous and current identities through `console.warn` and publishes the change unless that release was the last one dismissed. +3. `reload(id, rev)` (a rebuilt frame) returns at once while the Host serves another release. +4. `retry()` keeps reconciling the last applied graph, which always belongs to the boot release. +5. The UI's inject face reloads the page through `location.reload()`; `ClientEntries` does not navigate. + +### UI + +- Owner: [ui-settings-general](../../../../packages/client/ui-settings-general/src/client/index.ts), which already owns the connection pill (`ConnectionIndicator`) and the Desktop update prompt; its `inject` adds `modules`. +- It registers the `shell.overlay` entry `host-release-change` through `ctx.slots.inject('shell.overlay', ...)` and renders it with `Modal`. The inject face provides `hooks: { releaseChange: ctx.modules.entries.releaseChange }`, `dismissRelease`, and `reloadPage`. +- Copy lives in the zh/en locale dictionaries: a title, one sentence stating that live updates are paused until a reload, and two gray-labelled lines: + - **Service**: `previous` (gray) → `host` (solid), joined by a 1px solid green arrow because the Host already switched. + - **Page**: `page` (solid) → `host` (gray), joined by a 1px dashed gray arrow because only a reload completes the switch. Both connectors are a fixed 56px and do not stretch across the dialog. + - Each line shows versions when its two ends differ in version and commits otherwise; a release or field the page cannot read, including one the Host did not publish, shows **Unknown**. Screen readers read the two lines as text; the arrows are decorative. +- The buttons are **Not now**, which carries `data-modal-autofocus`, and **Reload to load the new version** (primary). The dialog opens without a user gesture, so initial focus keeps the page and an Enter typed into the composer cannot reload it and discard a draft. The close control, a mask click, and Escape act as **Not now** through the shared `Modal` close behavior. + +## Alternatives considered + +- **Call `location.reload()` automatically**: it can discard unsaved input, and the user should see and confirm the reload. +- **Carry the identity in the API ready frame (`ConnectionHostInfo`)**: when the protocol is incompatible, the ready frame itself may fail to parse, so detection would never run; pausing hot replacement would still belong to `ClientEntries`. +- **Let web-app compose the identity (CLI version + web-app version + commit) and pass it to modules through the `webApp` service and the composition**: it changes web-app's service interface, tests, docs, and the generated API catalog, and makes modules wait for web-runtime activation; the two versions release together, so reading them separately adds nothing. +- **Add a client-modules `commit` config field set from `!!js process.env.DSH_CLIENT_COMMIT_HASH` in the composition**: it reads the environment at run time, and released `dsh web` launches do not set the variable; the commit must be fixed at build time. +- **Read the CLI version by depending on app-boot from client-modules**: it adds a dependency edge, a dependency-policy classification, a tsconfig reference, and a lockfile change, while the package's own version is the same number. +- **Keep the decision logic inside `ClientEntries`**: reconciliation and release comparison are independent concerns; the separate gate keeps `ClientEntries` to a few added lines. +- **Let Not now apply the other release's plugin updates**: the page would run plugins of one release on the shell and static libraries of another, its state could no longer be named by one release, and returning to the boot release would need a second prompt whose two choices end in the same state. +- **Name the field `build`**: it is easily confused with the build process and build artifacts. +- **Reopen a permanently closed SSE stream in client-hmr**: in end-to-end restarts the 404 window never appeared, because the browser's own retry covered connection refusals; the gap is a documented limitation. +- **Store the decision in localStorage**: the decision concerns one page's code, and a reload replaces that code, so it should not outlive the page. +- **Remember every dismissed release**: a page that moves between several Host releases is rare, and asking again after another release appeared costs one click. +- **Keep the identity an opaque string and let the page split version and commit**: the display rule needs the two parts separately, and parsing a concatenated string breaks whenever its format changes. +- **Create a separate UI package**: it needs three more registrations, while ui-settings-general already owns connection state and update prompts. + +## Consequences + +- An upgraded Host or a dev Host at another commit never hot-replaces plugin code into a page of another release; the page keeps working at its own release until the user reloads. +- While the Host serves another release, plugin updates, including enabling or disabling a plugin on the Host, reach the page only after a reload. +- When a Host returns to the page's release, only modules whose revisions differ are replaced. Revisions come from artifact metadata, so a rebuild of identical code still replaces the rebuilt modules once, without a prompt. +- A composition without the dialog registered still pauses updates from another release and logs the warning; the user sees no prompt. +- The API connection and SSE are independent channels, so an old page can show a protocol error before the dialog appears. +- If a restarting Host answers `/plugins/events` with a non-200 status before registering the route, the browser closes that SSE stream for good, and the page cannot detect a release change until it reloads. +- A source launch without dev-web has no commit, so development detection falls back to version changes. +- Each tab prompts and remembers independently. +- Unit tests pin the gate through `ClientEntries` (same-release replacement, another release applying nothing, the return to the boot release replacing only changed modules, dismissal, absent releases), the manifest parser, the Host-published release, the shared preset's Node-half defines, and the dialog's rendering and callbacks. diff --git a/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.zh.md b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.zh.md new file mode 100644 index 0000000000..f8e8f85f71 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-14-host-release-change-refresh-prompt.zh.md @@ -0,0 +1,143 @@ +# Agent Note: Host release 变化时提示刷新页面,而不是跨 release 热替换 + +Status: implemented + +[English](2026-10-14-host-release-change-refresh-prompt.md) | 中文 + +## Problem + +`dsh web` 升级后,或开发者在另一个 commit 上重启 `pnpm run dev:web` 后,已打开的浏览器页面会自动重连到新 Host,但页面的静态 shell 仍停在旧 release。协议一旦不兼容,页面会一直重连失败或报错,用户也不知道需要刷新。 + +技术原因: + +- 重连后,[client-hmr](../../../../packages/client/hmr/src/client/index.ts) 会收到新 Host 的完整 graph,[ClientEntries](../../../../packages/client/modules/src/client/entries.ts) 按每行的 `rev`(bundle 的 mtime、ctime 和大小)热替换插件。Vite shell、静态库和 bootstrap 在页面生命周期内从不替换。 +- API 连接按精确键集合校验 ready 帧([remote-events.ts](../../../../packages/api/gateway/src/client/remote-events.ts)),协议一变,旧页面无法自行恢复。 +- 页面没有任何途径得知 Host 已换成另一个 release。 +- Desktop 不受影响:Host 被替换后,main 进程会重新导航窗口([main.ts](../../../../apps/desktop/src/main.ts))。 + +[client plugin loading model](../architecture/2026-07-23-client-plugin-loading-model.zh.md) 负责加载链与热替换,它写明静态库和 shell 需要刷新页面;本 Note 负责页面如何得知 Host 的 release 变了,以及之后怎么处理。 + +## Decision + +release 指 Host 的版本身份:版本号加可选的源码 commit。名字刻意区别于 build,后者指构建过程和构建产物。 + +改动落在三处:client-modules(产生、下发和比较 release 身份)、共享 client 构建预设(把 commit 固化进 Host 产物)、ui-settings-general(确认框)。web-app 和 client-hmr 不改。 + +### 行为 + +只有当前连接的 Host 提供的是页面启动时的 release,页面才应用 Host 的 graph 和 rebuilt 帧。所以页面运行的每个模块都属于启动 release:要么是原封不动的启动产物,要么是启动产物加上同一 release 的 Host 发布的热替换。 + +| 场景 | 行为 | +|---|---| +| Host 提供启动 release,graph 与已应用的相同 | 什么都不做。 | +| Host 提供启动 release,部分模块 revision 不同 | 照常只热替换变化了的模块;已弹出的对话框关闭。 | +| Host 提供另一个 release(版本号或 commit 不同) | 不应用它的任何内容,忽略 rebuilt 帧,对话框显示 Host 和页面的版本变化。 | +| 用户选择**刷新以加载新版本** | `location.reload()` 以 Host 的 release 启动页面。 | +| 用户选择**暂不刷新**、关闭按钮、点击遮罩或 Escape | 对话框关闭;页面保持启动 release,不应用该 Host release 的任何内容;该 release 的后续 graph 不再提示。 | +| Host 回到启动 release | 对话框关闭,照常按 revision 对比,只替换变化了的模块,没变的保持不动。 | +| Host 换到被撤回的那个以外的 release | 再次弹出对话框。 | + +页面只在内存里记住最近一次撤回的 Host release,不写 localStorage 或 IndexedDB。刷新后以新的启动 release 为比较基准。 + +### Release 身份 + +- 字段:`{ version, commit? }`,两个字段都相等才算同一 release。每次完整构建(包括正式发布)都会记录仓库 commit,所以页面比较的是两个字段;发布新版本时两者一起变。 +- `version` 取 client-modules 自己 package.json 的版本。所有包同一版本号发布,所以它等于 CLI 版本。 +- `commit` 在构建时固化。共享 client 构建预设 [tsdown.client.ts](../../../../packages/client/tsdown.client.ts) 给每个 client 包的 Node 半侧注入与浏览器半侧相同的公开 `process.env.DSH_CLIENT_*` 取值([`clientBuildValueDefines`](../../../../scripts/client-build-environment.ts) 只替换具体变量,保留 `process.env` 本身)。client-modules 的 Host 代码读 `process.env.DSH_CLIENT_COMMIT_HASH`,在构建产物里是常量。 +- 从 TypeScript 源码启动的 Host 没有构建这一步:`pnpm run dev:web` 把构建取值放进 Host 进程环境,因此读到的是同一个 commit;不经 dev-web 的源码启动只发布版本号。 +- 字段值属于 wire 数据,对话框原样显示,不做本地化。 + +### Wire + +``` +WebBootRelease { version: string; commit?: string } +WebBootGraph { + rev; entries; batches // unchanged + /** Host release identity; absent from a Host that publishes none. */ + release?: WebBootRelease +} +BootManifest { ...; release?: WebBootRelease } +``` + +- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) 在构造时计算一次 release,由 `compose()` 写入每个 graph;`release` 不计入 `graph.rev`。不新增配置项,组合配置不变。 +- `__DSH_BOOT__` 和每个 SSE `graph` 帧都带这个字段,所以一个 graph 帧同时携带 Host 的 release 和完整模块图;client-hmr 的 Host 半侧不变。rebuilt 帧与同一条 SSE 连接上前一个 graph 帧来自同一个 Host 进程,因此沿用那一帧的 release。 +- `parseBootManifest` 允许 `release` 缺省,拒绝非字符串的 `version` 或 `commit`,并丢弃身份以外的成员,因此相等判断只看这两个字段。`readRelease` 只读取收到的 graph 的 `release` 成员,所以页面先比较 release,再校验其余内容;读不出的 release 视为缺省。不认识该字段的旧页面会忽略它。 + +### 页面侧 + +release 比较在 [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts);`ClientEntries` 在 `sync` 和 `reload` 中调用它。 + +``` +interface ClientReleaseChange { + /** Release this page booted from; every module the page runs belongs to it. */ + readonly page: WebBootRelease | undefined + /** Release the Host served before its latest release change. */ + readonly previous: WebBootRelease | undefined + /** Release of the connected Host. */ + readonly host: WebBootRelease | undefined +} + +class ReleaseChangeGate { + /** Release change to announce; undefined while the Host serves the boot release or a dismissed one. */ + readonly change: ObservableSnapshot + /** Whether the connected Host serves the boot release; graphs and rebuilt frames apply only then. */ + get matches(): boolean + /** Record a received graph's release and update the announcement; true when the graph may be applied. */ + observe(release: WebBootRelease | undefined): boolean + /** Stop announcing the connected Host's release. */ + dismiss(): void +} + +class ClientEntries { + /** The gate's change. */ + readonly releaseChange: ObservableSnapshot + /** Stop announcing the connected Host's release; updates stay paused until the Host returns or the page reloads. */ + dismissRelease(): void +} +``` + +调用顺序: + +1. `ClientEntries` 以启动 manifest 的 `release` 创建 gate。 +2. `sync(graph)` 先用 `readRelease` 读出 graph 的 release 交给 `observe`,再解析其余部分,所以即使页面读不懂另一个 release 的格式也会弹框。启动 release 的 graph 经过解析后照常调和,原有的 revision 对比只替换变化了的模块。其他 release 的 graph 直接返回,不进入调和队列;它首次出现时用 `console.warn` 记录 Host 之前和当前的身份并发布变化,除非它就是最近一次撤回的 release。 +3. Host 提供其他 release 期间,`reload(id, rev)`(rebuilt 帧)立即返回。 +4. `retry()` 继续调和上次应用的 graph,它始终属于启动 release。 +5. UI 的 inject 面通过 `location.reload()` 刷新页面;`ClientEntries` 不负责导航。 + +### UI + +- 归属:[ui-settings-general](../../../../packages/client/ui-settings-general/src/client/index.ts),它已经拥有连接状态药丸(`ConnectionIndicator`)和 Desktop 更新提示;其 `inject` 增加 `modules`。 +- 它通过 `ctx.slots.inject('shell.overlay', ...)` 注册 `shell.overlay` 条目 `host-release-change`,用 `Modal` 渲染。inject 面提供 `hooks: { releaseChange: ctx.modules.entries.releaseChange }`、`dismissRelease` 和 `reloadPage`。 +- 文案在中英 locale 字典里:一个标题、一句说明热更新已暂停直到刷新,以及两行灰色标签的版本: + - **服务**:`previous`(灰)→ `host`(实色),1px 绿色实线箭头,表示 Host 已经切换。 + - **页面**:`page`(实色)→ `host`(灰),1px 灰色虚线箭头,表示刷新后才会切换。两条连接线固定 56px,不撑满对话框。 + - 每行两端版本号不同时显示版本号,否则显示 commit;页面读不出的 release 或字段(包括 Host 没发布的)显示**未解析**。读屏把两行当文字读,箭头只是装饰。 +- 按钮为**暂不刷新**(带 `data-modal-autofocus`)和**刷新以加载新版本**(主按钮)。对话框不是用户操作打开的,初始焦点落在保留页面一侧,用户在输入框里按下的回车不会刷新页面、丢掉草稿。关闭按钮、点击遮罩和 Escape 通过共用的 `Modal` 关闭行为等同**暂不刷新**。 + +## Alternatives considered + +- **自动调用 `location.reload()`**:可能丢失尚未保存的输入,而且应当让用户看到并确认刷新。 +- **把身份放进 API ready 帧(`ConnectionHostInfo`)**:协议不兼容时 ready 帧本身就可能解析失败,检测根本不会发生;暂停热替换仍然属于 `ClientEntries`。 +- **由 web-app 拼身份(CLI 版本 + web-app 版本 + commit),通过 `webApp` 服务和组合配置交给 modules**:要改 web-app 的服务接口、测试、文档和生成的 API 清单,还让 modules 等待 web-runtime 激活;两个版本号一起发布,分开读取没有区别。 +- **给 client-modules 加 `commit` 配置项,在组合配置里写 `!!js process.env.DSH_CLIENT_COMMIT_HASH`**:运行时读环境变量,而正式发布的 `dsh web` 不设置这个变量;commit 必须在构建时固化。 +- **client-modules 依赖 app-boot 读取 CLI 版本**:要新增依赖边、依赖策略分类、tsconfig 引用和 lockfile 改动,而本包自己的版本就是同一个数。 +- **把判断逻辑留在 `ClientEntries` 里**:调和与 release 比较是两件独立的事;拆出 gate 后 `ClientEntries` 只多几行。 +- **选择暂不刷新后应用另一个 release 的插件更新**:页面会在一个 release 的 shell 和静态库上运行另一个 release 的插件,其状态无法再用一个 release 描述;回到启动 release 时还得再问一次,而两个选择的结果完全相同。 +- **字段命名为 `build`**:容易和构建过程、构建产物混淆。 +- **在 client-hmr 里重开被永久关闭的 SSE**:端到端重启测试中从未出现 404 窗口,浏览器自带的重试已覆盖连接被拒;这个缺口记为已知限制。 +- **把选择写进 localStorage**:这个选择只关乎一个页面的代码,而刷新会替换这些代码,所以不应比页面活得更久。 +- **记住每一个撤回过的 release**:页面在多个 Host release 之间来回切换的情况很少,出现其他 release 后再问一次只多一次点击。 +- **身份保持一个不透明字符串,由页面拆出版本号和 commit**:显示规则需要分开这两部分,从拼接字符串里拆解,格式一变就会出错。 +- **新建一个独立 UI 包**:要多做三处注册,而 ui-settings-general 已经拥有连接状态和更新提示。 + +## Consequences + +- 升级后的 Host 或换了 commit 的开发 Host,永远不会把插件代码热替换进另一个 release 的页面;页面按自己的 release 继续工作,直到用户刷新。 +- Host 提供其他 release 期间,插件更新(包括在 Host 上启用或停用插件)要等刷新后才会到达页面。 +- Host 回到页面的 release 时,只替换 revision 不同的模块。revision 取自产物元数据,所以即使重新构建出的代码完全相同,也会把重建过的模块替换一次,不弹框。 +- 组合里没有注册确认框时,仍会暂停来自其他 release 的更新并记录警告,用户看不到提示。 +- API 连接和 SSE 是两条独立通道,旧页面可能在确认框出现之前就已显示协议错误。 +- 重启中的 Host 若在注册路由前对 `/plugins/events` 返回非 200 状态,浏览器会永久关闭这条 SSE,页面在刷新前检测不到 release 变化。 +- 不经 dev-web 的源码启动没有 commit,开发期只能检测到版本号变化。 +- 每个标签页各自提示、各自记录。 +- 单元测试通过 `ClientEntries` 固定 gate 的行为(同一 release 的替换、其他 release 不应用任何内容、回到启动 release 只替换变化了的模块、撤回、缺失 release),并覆盖 manifest 解析、Host 发布的 release、共享预设给 Node 半侧的 define,以及确认框的渲染与回调。 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml new file mode 100644 index 0000000000..d3002b42b6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for 2026-10-15-deepseek-search-fetch-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 .agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md +/agent-note-direct-deepseek-search-and-fetch-apis: + en: fb19be7e7fc65810 + zh: 865aaed5b7f802cc +/agent-note-direct-deepseek-search-and-fetch-apis/problem: + en: d1aad580a4db9872 + zh: 1eb3bac59353291d +/agent-note-direct-deepseek-search-and-fetch-apis/decision: + en: e70efbb3aa9d53b6 + zh: 1d25eae2461346df +/agent-note-direct-deepseek-search-and-fetch-apis/decision/protocol-selection-and-deployment: + en: ec653b59d62ed95f + zh: 0d675616059930ba +/agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input: + en: a95618f172ef63ee + zh: 306556be09c56bdf +/agent-note-direct-deepseek-search-and-fetch-apis/decision/fetch-metadata-and-ownership: + en: 8051417b08739ed0 + zh: c11d35a346014436 +/agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration: + en: d5a8e36b545f1c04 + zh: d741fd0789401c62 +/agent-note-direct-deepseek-search-and-fetch-apis/decision/supplementary-messages-fix: + en: 738e32b488bbc1a1 + zh: f33afe570f62599a +/agent-note-direct-deepseek-search-and-fetch-apis/alternatives-considered: + en: 641cbfefc76f2f95 + zh: e3e718473eace85f +/agent-note-direct-deepseek-search-and-fetch-apis/consequences: + en: a55da02f4d3c06c0 + zh: 3c1a624f03f34f28 +/agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption: + en: 41f9a331c7aca397 + zh: 17a0fd53e133c2f5 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md new file mode 100644 index 0000000000..1df773558d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md @@ -0,0 +1,84 @@ +# Agent Note: Direct DeepSeek Search and Fetch APIs + +Status: implemented + +English | [中文](2026-10-15-deepseek-search-fetch-api.zh.md) + +## Problem + +DeepSeek web retrieval through Messages requires an auxiliary model request for each query. Harness extracts sources and citation excerpts but discards the generated answer. The dedicated Search and Fetch APIs provide structured sources and Markdown directly, so retrieval can run without Harness starting that auxiliary generation. Adopting these APIs requires explicit selection of both operations, independent deployment addresses, and a mapping of Fetch results onto the existing fetch result. + +## Decision + +The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools, and [web-search-deepseek](../../../../packages/web/web-search-deepseek/README.md) keeps Messages search. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields. + +### Protocol selection and deployment + +The APIs register as `deepseek-search-api` and `deepseek-fetch-api`; Messages search keeps `deepseek-official`. The setting **Use the new Web Search API** (**启用新版 Web 搜索 API**) on the Web search page writes `web.searchProvider` and `web.fetchProvider` together: on selects both APIs, and off restores Messages search with local HTTP fetch, the shipped selection. The switch defaults off and takes effect after saving, because the web service reads both selections for each operation. Messages remains the default until the Search API launch is announced, so existing users keep working search without configuration. Each operation resolves one settings snapshot, including its endpoint and credentials. Both APIs read one credential and one API base, because the guide serves them from one base and the switch selects them together. + +The two protocols retain independent API bases. Messages keeps `https://api.deepseek.com/anthropic/v1` and appends `/messages`. Direct retrieval uses `web-deepseek-api.baseURL`, which defaults to the guide's public base `https://api.deepseek.com/v1/tools`, and appends `/search` or `/fetch`; the verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`. These prefixes are different and are never inferred from the hostname, so another deployment enters its complete base. The switch is already the explicit opt-in, so the documented public deployment serves as its default. A blank or unparseable base leaves both providers unavailable. + +The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A Session on the DeepSeek account route sends its account token instead, as Messages search does; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users can enable direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments keep Messages search. + +### Search results and model input + +Direct Search sends `query`, `count`, `need_content: false`, and `ori_question`. `count` is the configured value from 1 to 50, defaulting to the service's 10, unless the request's result bound is smaller; the tool always sends its limit, 8 by default. `ori_question` is the text of the initiating Session's latest human prompt with whitespace collapsed and cut to the API's 128-character limit. The API rejects blank or longer values, so a search without such a prompt omits the field, and `sendOriginalQuestion: false` disables it. In 2026-10-16 probes, different values left result order, scores, and summaries unchanged. Queries pass through unchanged, including the documented site, language, date, exclusion, and exact-phrase operators. The service enforces the 1–128 Unicode-character query constraint. Empty `results` is successful. + +The provider preserves service order and maps URL, title, `snippet`, and nullable `date` into shared sources. The current tool gives the same short excerpt to the model and search card. It does not consume `summary`, full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities. + +Direct retrieval emits no auxiliary model-request event because Harness makes no such request. The normal tool-call and tool-result events retain the query and model-visible result. This says nothing about the search service's internal use of models. Returned text remains external, untrusted data. + +### Fetch metadata and ownership + +Fetch sends a URL and returns Markdown without an extra Harness summarization request. The API supplies neither a final redirected URL nor a target HTTP status, and the shared result has no title field. The provider keeps the requested URL, returns the Markdown body, and reports HTTP 200 for a successful response: the Fetch API answers 404 when the URL is unavailable, blocked by site policy, or returns an error, or when no Markdown can be extracted. On 2026-10-16 an unresolvable host returned HTTP 500 instead, so every non-success status is a retrieval failure rather than a specific target diagnosis. The shared fetch result and its consumers stay unchanged. + +The HTTP provider retains its own public-address validation, connection pinning, and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content. + +### Default migration + +Changing the default to the Search API is a separate change after the launch is announced. API keys and account tokens both reach the public routes; deployments behind a gateway without `/v1/tools` routes need an explicit `web.searchProvider: deepseek-official` before the default changes, and a stored Messages selection keeps Messages. + +### Supplementary Messages fix + +The retained Messages path explicitly sends `thinking: { type: 'disabled' }` and records that same body before dispatch. This avoids reasoning work in an auxiliary request whose generated answer is discarded; it does not implement or control the new retrieval API. `tool_choice` remains omitted. The [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) owns compatibility for the optional logged field. + +## Alternatives considered + +**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request while retaining Messages as an explicit mode. + +**Make the Search API the default now.** The launch is not announced, and some gateways expose only Messages; their users would lose working search. + +**Reuse the Messages address or infer a route prefix.** The protocols and deployed prefixes differ. Separate bases preserve existing configurations and avoid sending a valid request to the wrong route. + +**Require an explicit Search API base.** Enabling the switch is already an explicit choice, and an empty default would make every user copy the documented public address. Other deployments still override it. + +**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside `web-search-deepseek` cannot change fetch routing, so turning it on left `web_fetch` on HTTP unless the user also edited `web.fetchProvider`; it also tied the APIs' lifecycle to Messages search, which the default migration may retire. Separate registrations let the web service's existing selection express the switch, and each package can change or retire alone. + +**Share one transport between Messages search and the new APIs.** Both resolve credentials through the same seams, `ctx.credentials` and `ctx.deepseekAccount`, so a shared module would save only the dispatch and failure-mapping code. It would make the Messages package depend on the new API package and tie their releases, while the default migration may retire Messages search. Each package keeps its own transport until then. + +**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential, and the switch selects them together; separate packages would duplicate the settings a user edits once. Each operation lives in its own module, so either API can change without the other. + +**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. The page needs only the DeepSeek choice, and other providers keep their own settings pages. + +**Let the model supply `ori_question` as a tool argument.** The API defines the field as the user's original question, which the latest human prompt already records. A tool argument would change the model-facing schema shared by every search provider and invite paraphrase. + +**Keep HTTP fetch when the switch turns on.** The switch adopts the new DeepSeek retrieval APIs, and leaving fetch on HTTP made Fetch API adoption depend on a hand-edited `web.fetchProvider`. HTTP fetch, with its own access policy, remains the selection whenever the switch is off, and configuration can still select either fetch provider alone. + +**Add an extraction tool or widen the fetch result.** The API retrieves one concrete web page as Markdown, which fits the existing fetch operation. Its success already implies a retrieved page, and the Markdown body carries the page heading, so widening the result would change every fetch consumer for little model-visible gain. + +## Consequences + +Retrieval no longer requires auxiliary model tokens in direct mode, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off. + +Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The current tool can issue four queries concurrently and fails the batch if one query fails. The provider has no shared rate scheduler, automatic retries, or fallback; other Sessions or processes can exhaust the same account allowance. + +## Verification and remaining API adoption + +Provider tests cover request mapping, empty results, malformed responses, independent and default bases, account-token authentication, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the default base, and switching both selections. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day. + +The following capabilities remain outside the implemented integration: + +- Separate model-facing `summary` from display `snippet`, with per-source and total output budgets and matching Session records. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16. +- Expose search syntax in provider-appropriate tool guidance and define a bounded localization input before forwarding it. API `localization` is an IP address, not an inferred city or UI language. +- Retain quality metadata before defining selection policies. `rerank_score` and `authority` describe service assessments, not guarantees of truth; a hard cutoff can discard useful niche sources. +- Add cancellation-aware scheduling and bounded retry ownership. The guide recommends waiting before retrying 408, 429, and 5xx; 402 requires funding first, and other 4xx require a changed request. Local pacing cannot enforce an account-wide quota across unrelated processes. diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md new file mode 100644 index 0000000000..997a7d5509 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md @@ -0,0 +1,84 @@ +# Agent Note: 直接接入 DeepSeek Search 与 Fetch API + +Status: implemented + +[English](2026-10-15-deepseek-search-fetch-api.md) | 中文 + +## 问题 + +通过 Messages 获取 DeepSeek 网页搜索结果时,每个查询都需要一次辅助模型请求。Harness 提取来源与引用摘录,却丢弃生成的回答。独立 Search 和 Fetch API 直接提供结构化来源与 Markdown,使检索无需由 Harness 发起这次辅助生成。接入这些 API 需要明确选择两种操作的提供方、独立配置部署地址,并把 Fetch 结果映射到现有抓取结果。 + +## 决策 + +2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它,[web-search-deepseek](../../../../packages/web/web-search-deepseek/README.zh.md) 保留 Messages 搜索。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段。 + +### 协议选择与部署 + +两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索保留 `deepseek-official`。网页搜索页上的设置 **Use the new Web Search API**(**启用新版 Web 搜索 API**)同时写入 `web.searchProvider` 和 `web.fetchProvider`:开启时选择两个 API,关闭时恢复内置选择,即 Messages 搜索与本地 HTTP 抓取。开关默认关闭,保存后生效,因为 Web 服务在每次操作时读取两项选择。在 Search API 正式发布公告之前,Messages 仍是默认值,已有用户无需配置即可继续使用搜索。每次操作解析一份设置快照,包含端点和凭据。两个 API 读取同一份凭据和同一个 API 基础地址,因为指南从同一基础地址提供它们,开关也同时选择它们。 + +两种协议分别保存 API 基础地址。Messages 保留 `https://api.deepseek.com/anthropic/v1`,追加 `/messages`。直接检索使用 `web-deepseek-api.baseURL`,默认值为指南中的公网地址 `https://api.deepseek.com/v1/tools`,追加 `/search` 或 `/fetch`;已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`。两者前缀不同,程序不会根据主机名推断,其他部署需填写完整基础地址。开关本身已是显式启用,因此以文档中的公网部署作为默认值。基础地址为空白或无法解析时,两个提供方都不可用。 + +指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。DeepSeek 账号路由上的会话改为发送其账号 token,与 Messages 搜索一致;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也可以启用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署继续使用 Messages 搜索。 + +### 搜索结果与模型输入 + +直接 Search 发送 `query`、`count`、`need_content: false` 和 `ori_question`。`count` 为 1 至 50 的配置值,默认采用服务的 10;请求的结果上限更小时以后者为准,工具总会发送其上限,默认为 8。`ori_question` 是发起会话中最近一条用户提示的文本,空白折叠后截取至 API 的 128 个字符上限。API 拒绝空白或更长的值,因此没有此类提示的搜索不发送该字段,`sendOriginalQuestion: false` 可将其关闭。2026-10-16 的探测中,不同取值未改变结果顺序、评分或摘要。查询原样传递,包括文档中的站点、语言、日期、排除和精确短语操作符。服务端执行查询长度为 1–128 个 Unicode 字符的约束。空 `results` 表示成功。 + +提供方保留服务端顺序,将 URL、标题、`snippet` 和可为 null 的 `date` 映射为共享来源。当前工具将同一段短摘要提供给模型和搜索卡片。它不使用 `summary`、完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。 + +直接检索不产生辅助模型请求事件,因为 Harness 没有发起该请求。普通工具调用与工具结果事件继续保留查询及模型可见的结果。这不代表搜索服务内部不使用模型。返回文本仍是外部不受信任的数据。 + +### 抓取元数据与职责 + +Fetch 发送 URL,返回 Markdown,无需 Harness 额外发起摘要请求。API 既不提供重定向后的最终 URL,也不提供目标 HTTP 状态,而共享结果没有标题字段。提供方保留请求 URL,返回 Markdown 正文,并对成功响应报告 HTTP 200:URL 不可用、被站点策略拦截、目标站点返回错误或无法提取 Markdown 时,Fetch API 会返回 404。2026-10-16 无法解析的主机实际返回 HTTP 500,因此任何非成功状态都视为抓取失败,而不是对目标的具体诊断。共享抓取结果及其消费方保持不变。 + +HTTP 提供方继续负责自身的公开地址校验、连接固定和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。 + +### 默认值迁移 + +将默认值改为 Search API 是发布公告之后的另一项变更。API 密钥和账号 token 都能访问公网路由;位于没有 `/v1/tools` 路由的网关之后的部署,需要在默认值变更前显式设置 `web.searchProvider: deepseek-official`,已保存的 Messages 选择仍使用 Messages。 + +### 附带的 Messages 修复 + +保留的 Messages 路径显式发送 `thinking: { type: 'disabled' }`,并在发送前记录同一请求体。这避免了辅助请求在生成回答被丢弃的情况下执行思考;它不实现或控制新的检索 API。`tool_choice` 仍不传递。[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)负责日志中可选字段的兼容性。 + +## 考虑过的替代方案 + +**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求,同时保留 Messages 供显式选择。 + +**立即将 Search API 设为默认值。** 尚未发布公告,部分网关也只提供 Messages;这些用户会失去可用的搜索。 + +**复用 Messages 地址或推断路由前缀。** 协议与部署前缀不同。分别保存基础地址可保留现有配置,避免把有效请求发往错误路由。 + +**要求显式填写 Search API 基础地址。** 开启开关已是显式选择,空默认值会迫使每位用户复制文档中的公网地址。其他部署仍可覆盖它。 + +**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** `web-search-deepseek` 内的开关无法改变抓取路由,因此开启后除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍走 HTTP;它还把两个 API 的生命周期绑在 Messages 搜索上,而默认值迁移后 Messages 搜索可能被移除。独立注册让 Web 服务现有的选择来表达这个开关,每个包都可以单独修改或移除。 + +**让 Messages 搜索与新 API 共用一套传输层。** 两者都通过同样的 seam,即 `ctx.credentials` 和 `ctx.deepseekAccount` 解析凭据,共享模块只能省下请求派发与失败映射代码。它会让 Messages 包依赖新 API 包、两者必须一起发版,而默认值迁移后 Messages 搜索可能被移除。在此之前,两个包各自保留传输层。 + +**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API,开关也同时选择它们;拆成两个包会让用户只需编辑一次的设置重复出现。每种操作位于独立模块,任一 API 都可以单独修改。 + +**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。该页面只需要 DeepSeek 的选择,其他提供方保留各自的设置页。 + +**让模型以工具参数提供 `ori_question`。** API 将该字段定义为用户的原始问题,而最近一条用户提示已记录了这段文本。工具参数会改变所有搜索提供方共用的面向模型的 schema,并引入改写。 + +**开启开关时保留 HTTP 抓取。** 该开关用于启用 DeepSeek 新的检索 API,保留 HTTP 抓取会让使用 Fetch API 依赖手动修改 `web.fetchProvider`。开关关闭时,抓取仍选择带有自身访问策略的 HTTP 提供方,配置中也仍可单独选择任一抓取提供方。 + +**新增提取工具或扩展抓取结果。** API 以 Markdown 获取一个具体网页,符合现有抓取操作。其成功本身即表示已获取页面,Markdown 正文也带有页面标题,因此扩展结果会改动每个抓取消费方,而模型可见的收益很小。 + +## 影响 + +直接模式中的检索不再需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。 + +Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。当前工具可并发发送四个查询,任一查询失败会使整批失败。提供方没有共享限速调度器、自动重试或回退;其他会话或进程也会消耗同一账号的额度。 + +## 验证与尚未接入的 API 能力 + +提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与默认地址、账号 token 鉴权、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、默认地址和同时切换两项选择。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。 + +以下能力尚未包含在已实现的接入中: + +- 区分面向模型的 `summary` 与用于展示的 `snippet`,设置每个来源和总输出限额,并记录对应的 Session 内容。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。 +- 在适用于提供方的工具说明中介绍搜索语法,先定义有界的本地化输入,再进行转发。API 的 `localization` 是 IP 地址,不能用推测的城市或界面语言代替。 +- 先保留质量元数据,再定义筛选策略。`rerank_score` 与 `authority` 表示服务端评估,不保证内容真实;固定阈值可能丢弃有用的冷门来源。 +- 增加支持取消的调度并明确有限重试的职责。指南建议对 408、429 和 5xx 等待后重试;402 需要先充值,其他 4xx 需要修改请求。本地节流无法跨无关进程执行账号级配额。 diff --git a/.agents/notes/implemented/feature/2026-10-18-headless-auto-review.i18n.yaml b/.agents/notes/implemented/feature/2026-10-18-headless-auto-review.i18n.yaml new file mode 100644 index 0000000000..d75763c869 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-18-headless-auto-review.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-18-headless-auto-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/feature/2026-10-18-headless-auto-review.md +/agent-note-headless-caller-authority-for-auto-review: + en: 9be347ac387545f7 + zh: b360764b7d22d1d6 +/agent-note-headless-caller-authority-for-auto-review/problem: + en: 01dcd32fe547f90c + zh: f9f577c4d72ebdbe +/agent-note-headless-caller-authority-for-auto-review/decision: + en: 372c020d57c6186a + zh: 31c00bb5175930b1 +/agent-note-headless-caller-authority-for-auto-review/alternatives-considered: + en: 7e7aefd55c59837c + zh: e119a25a2171d376 +/agent-note-headless-caller-authority-for-auto-review/consequences: + en: e3c7afb422c73478 + zh: 2acb65eaa03ad716 diff --git a/.agents/notes/implemented/feature/2026-10-18-headless-auto-review.md b/.agents/notes/implemented/feature/2026-10-18-headless-auto-review.md new file mode 100644 index 0000000000..f52d75f35c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-18-headless-auto-review.md @@ -0,0 +1,33 @@ +# Agent Note: Headless caller authority for Auto review + +Status: implemented + +English | [中文](2026-10-18-headless-auto-review.zh.md) + +## Problem + +Headless, SDK, and ACP admit tasks with `source.kind: 'user'`. Auto must recognize those tasks as instructions without requiring a browser RPC identity. The message role alone is insufficient: injected context also uses the user role but names its own source. + +## Decision + +The [experimental Auto layer](../../../../packages/experimental/auto-review/README.md#headless-supervision) supports explicit headless selection. Default profiles remain unchanged. This extends the Web-only scope of [per-tool Auto review](2026-08-28-auto-review.md); its risk classes, full-access execution, PTC exclusions, and lifecycle remain authoritative. + +The reviewer assigns user-sourced text the `user-instruction` role, including existing history and tasks admitted before Auto was selected. The user may be a human or a supervising caller. Both can define the task and authorize an exact medium-risk action, target, and scope. The current child's creation prompt and direct-parent messages keep their subordinate role; inherited and unverified creation prompts remain facts. Files, constraints, checkpoints, images, and tool results do not acquire instruction authority. Input producers own the source attribution; task text never selects its source. The message types and persisted fields remain unchanged. + +Headless applies an explicit `--approval-policy` after selecting the permission preset. Saved policy continues when both options are omitted on resume. Pass `--permission-mode auto --approval-policy never` to select unattended Auto explicitly. This retains the structured denial and avoids dependence on a human answerer. `ask` still follows [the approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md); it does not add a CLI approval transport. Application shutdown preserves the saved Auto selection. Restoring it requires the integration even when selecting another preset; removing the plugin while the application remains active still migrates live Sessions to Full access. + +The JSON projection includes existing durable error fields under its normal output bounds. A final denial carries `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` and optional reviewer detail; a technical or parsing failure carries `AutoReviewFailedError` / `AUTO_REVIEW_FAILED`. Neither executes the tool body. Completed agent execution does not imply that every requested action succeeded. + +## Alternatives considered + +**Reuse a browser RPC id.** A headless invocation is not a browser request. Faking that identity would couple authorization to a different transport and misdescribe the persisted source. + +**Add a headless admission marker.** The existing user source already identifies task input. A transport-specific marker adds persisted metadata and reader rules without changing the required authority decision. + +**Promote every user-role message.** Plugins also supply user-role context. The existing source discriminator distinguishes admitted user input from injected content. + +**A live caller-approval channel.** Headless consumes stdin as one task. Approving a suspended call needs a bidirectional protocol with correlation, disconnect and cancellation handling, and an answerer lifecycle. A later invocation can instead supply scoped authorization and receive a fresh review, without claiming to approve the earlier call. + +## Consequences + +A supervisor can use Auto for implementation and gates, inspect structured denials, and resume with revision instructions. Native tools keep all supported tool bodies under review; direct Node effects inside PTC remain outside it. The reviewer remains a fallible model with full-access consequences, and the integration remains experimental and opt-in. Custom producers that use the user source are treated as the user; background context must use its own source. diff --git a/.agents/notes/implemented/feature/2026-10-18-headless-auto-review.zh.md b/.agents/notes/implemented/feature/2026-10-18-headless-auto-review.zh.md new file mode 100644 index 0000000000..dff1acc9e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-18-headless-auto-review.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Headless 调用者对 Auto review 的授权 + +Status: implemented + +[English](2026-10-18-headless-auto-review.md) | 中文 + +## Problem + +Headless、SDK 和 ACP 以 `source.kind: 'user'` 接纳任务。Auto 必须将这些任务识别为指令,而不要求浏览器 RPC 身份。仅有消息角色不足以判断:注入上下文也使用 user role,但声明自己的 source。 + +## Decision + +[实验性 Auto 层](../../../../packages/experimental/auto-review/README.zh.md#headless-supervision)支持显式 headless 选择。默认 profile 保持不变。这扩展了[逐工具 Auto review](2026-08-28-auto-review.zh.md)仅限 Web 的范围;其风险分类、完全访问执行、PTC 排除项和生命周期仍具权威性。 + +Reviewer 将 user-source 文本归为 `user-instruction`,包括已有历史和选择 Auto 前接纳的任务。用户可以是人或监督调用者;两者都可定义任务,并明确授权 medium 风险动作、准确目标和范围。当前 child 的创建 prompt 和直接父级消息保留从属角色;继承的创建 prompt 与无法验证的创建 prompt 仍是事实。文件、约束、checkpoint、图片和工具结果不会获得指令权威。输入生产者负责来源归属;任务文本不能选择自己的 source。消息类型和持久字段保持不变。 + +Headless 在选择权限预设后应用显式 `--approval-policy`。恢复时省略两个选项会延续已保存的策略。传入 `--permission-mode auto --approval-policy never` 可显式选择无人值守 Auto。这保留结构化拒绝,并避免依赖人工应答者。`ask` 仍遵循[审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md);它不添加 CLI 审批传输。应用关闭保留已保存的 Auto 选择。恢复时需要该 integration,即使要选择其他预设也是如此;应用仍运行时移除插件则仍将存活 Session 迁移到 Full access。 + +JSON 投影在正常输出限制内包含已有的持久错误字段。最终拒绝携带 `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` 和可选 reviewer 详情;技术或解析失败携带 `AutoReviewFailedError` / `AUTO_REVIEW_FAILED`。两者都不执行工具 body。Agent 执行完成不代表每个请求动作都成功。 + +## Alternatives considered + +**复用浏览器 RPC id。** Headless 调用不是浏览器请求。伪造该身份会将授权耦合到另一种传输,并错误描述持久来源。 + +**添加 headless 入口标记。** 已有 user source 已标识任务输入。传输专属标记增加持久元数据和读取规则,却不改变所需的权威判断。 + +**提升每条 user-role 消息。** 插件也提供 user-role 上下文。已有 source 判别字段区分接纳的用户输入与注入内容。 + +**实时调用者审批通道。** Headless 将 stdin 作为一个任务消费。审批暂停的调用需要双向协议、请求关联、断连与取消处理,以及应答者生命周期。后续调用可以改为提供限定范围的授权并接受新审查,不声称批准了先前调用。 + +## Consequences + +监督者可以使用 Auto 实现修改和运行检查、查看结构化拒绝,并通过修订指令恢复。原生工具让所有支持的工具 body 都经过审查;PTC 内的直接 Node 效果仍不在范围内。Reviewer 仍是可能出错的模型,执行具有完全访问权限的后果,integration 仍保持实验性与显式启用。使用 user source 的自定义生产者被视为用户;后台上下文必须使用自己的 source。 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml deleted file mode 100644 index 18ab194298..0000000000 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Bilingual-pair consistency record for 2026-07-06-node-engine-floor.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-07-06-node-engine-floor.md -/agent-note-raise-the-node-lts-engine-floor-to-22-19: - en: 01f43f2b4c6bd4fa - zh: 7f34e5b3c1c55419 -/agent-note-raise-the-node-lts-engine-floor-to-22-19/problem: - en: d7bf1b1699ce0dc8 - zh: f10dfd0e4787bb44 -/agent-note-raise-the-node-lts-engine-floor-to-22-19/decision: - en: 0051c0d8c1948f63 - zh: cbab7aa0f53f7200 -/agent-note-raise-the-node-lts-engine-floor-to-22-19/consequences: - en: 0b9fbe932d9a8b1f - zh: 642e19d06caecef2 -/agent-note-raise-the-node-lts-engine-floor-to-22-19/alternatives-considered: - en: c433389b2064eaf9 - zh: 57de2af812def1c4 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index de88f44003..d13a9fab6d 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -9,8 +9,8 @@ en: 3ecedc74cceced3b zh: f4708f1887d42c61 /agent-note-ci-failover-runbook-hosted-pools-in-house-pool/decision: - en: fbc8d8ef03350ac9 - zh: 7fc81bf75eaab0b7 + en: 4a76615718dbc8f0 + zh: f46b885a11d5dd1d /agent-note-ci-failover-runbook-hosted-pools-in-house-pool/decision/release-rehearsals-share-the-linux-switch: en: 01e74068e0a96375 zh: aa811861ce30cb47 @@ -24,8 +24,8 @@ en: 2e3829fd72d6b1b0 zh: 563f0abade7974f9 /agent-note-ci-failover-runbook-hosted-pools-in-house-pool/capacity-during-failover: - en: 22611ed4316adc7c - zh: 29acd02b4a07313d + en: 3dffe12ecf2356c2 + zh: 7e2385fcabab1398 /agent-note-ci-failover-runbook-hosted-pools-in-house-pool/capacity-during-failover/switch-back: en: 63cf911019db8d2f zh: 5eb3903b0ceb30db diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index f745cb4e70..a4be39a582 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym ## Decision -The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes. +The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the two `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes. The [superseded-CI cancellation policy](../../../../.github/workflows/ci.yml) governs master pushes and manual runs in the same workflow/ref group, including standby drills. Rapid master updates can starve a drill before it reaches a verdict. Use the latest completed standby verdict and check its age and commit before treating it as readiness evidence; a cancelled or merely scheduled run is not proof of readiness. @@ -40,7 +40,7 @@ The two switches are independent: flip only the one whose platform is degraded. ## Capacity during failover -Capacity includes the master standby, main-CI jobs, and three release-rehearsal jobs for each eligible PR or master push while the Linux switch is set. Each trusted PR also adds three Node compatibility jobs at gate concurrency one, including the build-backed Node 22 leg and cold temporary runtime downloads. The release rehearsal workflows cancel superseded runs within each workflow/ref group under the [cancellation policy](../../../../.github/workflows/ci.yml); different refs can still add concurrent build, pack, and install load. Check current CPU, memory, disk, and queue pressure before extending self-hosted operation; extra registrations on this VM add scheduling slots, not machine resources. Do not infer spare capacity from the standby alone. When host resources permit extra registrations, use an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; a started service adds a scheduling slot, not CPU or memory. +Capacity includes the master standby, main-CI jobs, and three release-rehearsal jobs for each eligible PR or master push while the Linux switch is set. Each trusted PR also adds two Node compatibility jobs at gate concurrency one, including the build-backed Node 24.9 leg and cold temporary runtime downloads. The release rehearsal workflows cancel superseded runs within each workflow/ref group under the [cancellation policy](../../../../.github/workflows/ci.yml); different refs can still add concurrent build, pack, and install load. Check current CPU, memory, disk, and queue pressure before extending self-hosted operation; extra registrations on this VM add scheduling slots, not machine resources. Do not infer spare capacity from the standby alone. When host resources permit extra registrations, use an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; a started service adds a scheduling slot, not CPU or memory. ### Switch back diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 3e51f00298..fafeda5b1d 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。 +三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、两个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。 [被取代 CI 的取消策略](../../../../.github/workflows/ci.yml) 管理同一工作流/引用组内的 master 推送和手动运行,包括热备演练。master 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。 @@ -40,7 +40,7 @@ Status: implemented ## 切换期间的容量 -Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以及每个符合条件的 PR 或 master 推送的三个发布演练作业。每个可信 PR 还会增加三个门禁并发度为一的 Node 兼容性作业,包括需要构建的 Node 22 条目和冷临时运行时下载。发布演练工作流依据[取消策略](../../../../.github/workflows/ci.yml)取消各工作流/引用组内被取代的运行;不同引用仍可能增加并发构建、打包和安装负载。延长自托管运行前,检查当前 CPU、内存、磁盘和队列压力;同一虚拟机上新增注册只增加调度槽位,不增加机器资源。不能只依据热备负载推断空闲容量。主机资源允许增加注册实例时,使用组织级注册 token(组织 Settings → Actions → Runners → New runner)。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;启动服务增加的是调度槽位,而非 CPU 或内存。 +Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以及每个符合条件的 PR 或 master 推送的三个发布演练作业。每个可信 PR 还会增加两个门禁并发度为一的 Node 兼容性作业,包括需要构建的 Node 24.9 条目和冷临时运行时下载。发布演练工作流依据[取消策略](../../../../.github/workflows/ci.yml)取消各工作流/引用组内被取代的运行;不同引用仍可能增加并发构建、打包和安装负载。延长自托管运行前,检查当前 CPU、内存、磁盘和队列压力;同一虚拟机上新增注册只增加调度槽位,不增加机器资源。不能只依据热备负载推断空闲容量。主机资源允许增加注册实例时,使用组织级注册 token(组织 Settings → Actions → Runners → New runner)。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;启动服务增加的是调度槽位,而非 CPU 或内存。 ### 切回 diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml index e3b23f9d2c..6fd2312592 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml @@ -12,8 +12,8 @@ en: a15c7c13f167e5ac zh: 63bcc2ef60c2f344 /agent-note-published-dependency-faces-and-bounded-peer-relays/decision/package-selection: - en: 5ea13b09ae774437 - zh: ef9dbccada39107a + en: 931b678bcdb42dbe + zh: 95dbebdb03c50d1f /agent-note-published-dependency-faces-and-bounded-peer-relays/decision/dependency-sections: en: d34ebac6c1c7642b zh: 2cf3a60df14b072a diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md index ac0550750f..0c2ecbac07 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md @@ -14,7 +14,7 @@ The package that chooses a Client build input is the shipped profile, while a Ho ### Package selection -[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every non-experimental package that declares `dsh.client`. Inside the directory, `dsh.client` marks a Client/Host package whose Host entry is scanned; a package without that declaration is a Client-only static build input. Outside the directory, `dsh.client` selects the same Client/Host scan. A `"./client"` export alone is an API and does not select npm dependency policy. +[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) owns dependency-section policy. It always covers packages under `packages/client/` and every package that declares `dsh.client`, experimental packages included. Inside the directory, `dsh.client` marks a Client/Host package whose Host entry is scanned; a package without that declaration is a Client-only static build input. Outside the directory, `dsh.client` selects the same Client/Host scan. A `"./client"` export alone is an API and does not select npm dependency policy. [Experimental packages follow the same dependency declaration rules](2026-10-14-experimental-dependency-policy.md) records why the selection includes experimental packages. [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) provides explicit Client-face include and exclude lists. An include handles an exceptional package without `dsh.client`, while an exclude removes an automatically discovered dual-face package outside `packages/client/`. The verifier rejects unknown, stale, redundant, duplicate, overlapping, and ineffective entries. The include list is empty; the exclude list contains `@deepseek-ai/dsh-api-session-controller` and `@deepseek-ai/dsh-api-workspace-controller`. Adding Session Controller back would migrate nine more Host edges while its five-run candidate retest improved median resolution by only 0.15 seconds. diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md index 5ba6333ae4..506f209229 100644 --- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md +++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md @@ -14,7 +14,7 @@ Client 构建输入由发布 profile 选择,而 Host value import 由导入它 ### 包选择 -[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个非实验包。在该目录内,`dsh.client` 标记需要扫描 Host 入口的 Client/Host 包;没有该声明的包是仅供 Client 编译的静态输入。在目录外,`dsh.client` 选择相同的 Client/Host 扫描。仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。 +[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 统一负责依赖区段策略。它始终覆盖 `packages/client/` 下的包,以及声明 `dsh.client` 的每个包,实验包也包括在内。在该目录内,`dsh.client` 标记需要扫描 Host 入口的 Client/Host 包;没有该声明的包是仅供 Client 编译的静态输入。在目录外,`dsh.client` 选择相同的 Client/Host 扫描。仅有 `"./client"` export 只是 API,不参与 npm 依赖策略选包。[实验包遵守同一套依赖声明规则](2026-10-14-experimental-dependency-policy.zh.md)记录了选包为何包括实验包。 [`package-dependency-policy.ts`](../../../../scripts/package-dependency-policy.ts) 提供显式 Client 门面 include 与 exclude 列表。include 用于没有 `dsh.client` 的例外包,exclude 用于移除 `packages/client/` 之外自动发现的双面包。验证器拒绝未知、失效、冗余、重复、相互重叠和无法生效的配置项。include 列表为空;exclude 列表包含 `@deepseek-ai/dsh-api-session-controller` 和 `@deepseek-ai/dsh-api-workspace-controller`。把 Session Controller 加回会多迁移九条 Host 边,而五次候选复测的 resolver 中位数仅改善 0.15 秒。 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 192e40c2d2..1ab57007ef 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: af93d8849bb8831a - zh: 53e15e801ab844da + en: 26a903fdfeffcea7 + zh: f6ea094ec32a7b14 /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 6c422a612d..b6f5bc1743 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 @@ -10,7 +10,7 @@ PR jobs need the same compiled packages for benchmarks, compatibility, packaging ## Decision -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. +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-10-15-node-24-runtime-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. That artifact build is `pnpm run build:artifacts`; [the build split](2026-10-11-build-artifacts-check-split.md) owns its steps and scheduling. @@ -20,9 +20,9 @@ The Python executable builder selects `build:artifacts` artifact compilation wit 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. +Job inventory, runner selectors, and benchmark sample counts stay fixed; gate and worker ceilings do not increase. The two 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 24.9 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. -Each Node version runs the same seven source compatibility specs in one serial Vitest invocation. Both file parallelism and the CLI worker limit are fixed to one, and the gate sets `VITEST_MAX_WORKERS=1` because that environment variable overrides Vitest's CLI limit. Files retain fork isolation; the build-backed Node 22 lazy-search smoke keeps its separate build dependency. Three paired local samples preserve all 253 assertion outcomes and reduce startup-only median time from 11.52 to 8.69 seconds, without increasing owned descendant-process peak. +Each Node version runs the same eight source compatibility specs in one serial Vitest invocation. Both file parallelism and the CLI worker limit are fixed to one, and the gate sets `VITEST_MAX_WORKERS=1` because that environment variable overrides Vitest's CLI limit. Files retain fork isolation; the build-backed Node 24.9 lazy-search smoke keeps its separate build dependency. The original seven-spec paired samples preserve all 253 assertion outcomes and reduce startup-only median time from 11.52 to 8.69 seconds, without increasing owned descendant-process peak. ## Alternatives considered 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 9f25366401..52756c1176 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 @@ -10,7 +10,7 @@ PR job 需要相同的编译包来运行 benchmark、兼容性、打包和运行 ## Decision -必需的 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)仍然有效。 +必需的 Linux consumer 与原生 Windows build job 保留普通构建,包括两个完整 aggregate program。公开的 `build` 和 `typecheck` 保留相同检查。[Node 版本职责](2026-10-15-node-24-runtime-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 工作流构建保留普通检查。该产物构建即 `pnpm run build:artifacts`,其步骤与调度见[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)。 @@ -20,9 +20,9 @@ Python executable builder 在验证 runtime closure 后,通过 `--build-artifa 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 顺序。 +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 24.9 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。 -每个 Node 版本在一次串行 Vitest 调用中运行相同的七个 source compatibility spec。文件并行和 CLI worker 上限均固定为一,gate 还设置 `VITEST_MAX_WORKERS=1`,因为该环境变量会覆盖 Vitest 的 CLI 上限。文件保持 fork 隔离;依赖构建的 Node 22 lazy-search smoke 保留独立的 build 依赖。三对本地样本保持全部 253 个 assertion outcome,将启动开销比较的 median 从 11.52 秒降至 8.69 秒,且不增加所拥有 descendant process 的峰值。 +每个 Node 版本在一次串行 Vitest 调用中运行相同的八个 source compatibility spec。文件并行和 CLI worker 上限均固定为一,gate 还设置 `VITEST_MAX_WORKERS=1`,因为该环境变量会覆盖 Vitest 的 CLI 上限。文件保持 fork 隔离;依赖构建的 Node 24.9 lazy-search smoke 保留独立的 build 依赖。原来七个 spec 的成对样本保持全部 253 个 assertion outcome,将启动开销比较的 median 从 11.52 秒降至 8.69 秒,且不增加所拥有 descendant process 的峰值。 ## Alternatives considered 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 index 84099cc7a6..47ec4b3d54 100644 --- 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 @@ -21,8 +21,8 @@ en: 4b324f92307d3691 zh: e0cf4411a747f7e5 /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/callers: - en: 55c55eea07fdb459 - zh: ab78cdfa4d04a5e7 + en: 7e22307c84a043d2 + zh: 73edd48af9ee10ee /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/measurements: en: b3a753ac2aab6073 zh: 19cbd0e32751896f 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 index 4476424af5..9d085f6598 100644 --- 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 @@ -67,7 +67,7 @@ native → host-src ─┬─ host-check ───────────── | `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 node-compat 24.9 | `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 | 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 index 4929e28574..b030d42b36 100644 --- 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 @@ -67,7 +67,7 @@ native → host-src ─┬─ host-check ───────────── | `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 node-compat 24.9 | `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` | 无 | diff --git a/.agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.i18n.yaml b/.agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.i18n.yaml new file mode 100644 index 0000000000..25c2758dcd --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-14-experimental-dependency-policy.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-14-experimental-dependency-policy.md +/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules: + en: ec44b9c40ce4bb25 + zh: 2e5731c96fa82ba8 +/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/problem: + en: ee78aec260872b62 + zh: 005e4c3c2b54a9ef +/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/decision: + en: 081508647fade940 + zh: 496cffe0fba8544f +/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/alternatives-considered: + en: 28c320fb3547502d + zh: 9563fd1709ed8b54 +/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/consequences: + en: cc50e617e4893a30 + zh: c2d2b9efd2955127 +/agent-note-experimental-packages-follow-the-same-dependency-declaration-rules/testing: + en: 9542331ca97ac00a + zh: 34857e7b85128c7b diff --git a/.agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.md b/.agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.md new file mode 100644 index 0000000000..b76da19d33 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.md @@ -0,0 +1,40 @@ +# Agent Note: Experimental packages follow the same dependency declaration rules + +Status: implemented + +English | [中文](2026-10-14-experimental-dependency-policy.zh.md) + +## Problem + +A [verify-package-dependencies](../../../../scripts/verify-package-dependencies.ts) scope of `packages/!(experimental)/*/package.json` exempts the whole experimental group from the rule that browser and type relationships belong only in devDependencies ([client dependency rules](../../../../packages/client/AGENTS.md#dependency-declaration)), and [published dependency faces](2026-08-26-published-dependency-faces.md) stated its coverage as "non-experimental packages". Under that scope, 4 experimental client packages declared client, api, session, and similar packages as peerDependencies. The webworker packer follows workspace peers into the image, so browser static libraries became sweep roots, and packing agent-team-profile produced 82 unresolvable requests. The [experimental package rules](../../../../packages/experimental/AGENTS.md) state that experimental status does not relax engineering requirements, which that exemption contradicts. + +Checking the 6 experimental packages that declare `dsh.client` under the same rules found 46 violations across the full run, all in experimental packages. In addition, a verifier that traces runtime imports only from the Host entry misclassifies inspector's `ws`, which is used only in its worker_threads entry, as devDependencies-only. + +## Decision + +- The check reads `packages/*/*/package.json`, so experimental packages follow exactly the release rules with no group-level exemption, and the dependency-section check carries no other experimental exception. +- The verifier also scans each package's own Host-side worker and child-process entries, identified from the build entries the package's `tsdown.config.ts` declares. `readHostBuildEntries` imports that config, evaluates its default export once under the `host` build face and once under the `client` build face (`DSH_BUILD_FACE`), combines the results, and keeps entries whose platform is not browser and whose source is outside `src/client/`. The verifier does not guess from source patterns. The configurations can only be imported asynchronously, so the verifier's state reading is async. +- The affected experimental manifests and the lockfile follow the policy; every entry moved out of `peerDependencies` or `dependencies` was confirmed not to be a runtime requirement. cot-translation applies `instanceof` to translator's `TranslationError`, which requires the same class instance, so `peerRequiredHostExports` classifies that export. +- Host-only experimental packages, like release packages, are checked only when listed in `hostPackages`. Whether to extend the check to every Host-only package is a separate decision. +- This decision partially supersedes the package-selection coverage of [published dependency faces](2026-08-26-published-dependency-faces.md), whose package-selection paragraph links back here; that note's other decisions remain in force. + +## Alternatives considered + +**Fix only these manifests without changing the check scope.** The same declarations would soon return; a rule without an enforcement point is not a rule. + +**Skip browser static libraries as sweep roots in the packer.** This only works around the symptom; the incorrect manifests still affect npm installation and other consumers. + +## Consequences + +`pnpm run verify-package-dependencies` covers experimental packages that declare `dsh.client`, so the manifests it accepts let the webworker packer pack agent-team-profile and voice-input-bundle, and inspector's published manifest keeps its worker runtime dependencies. + +The trade-off costs: + +- Published experimental manifests list browser and type relationships only as dev dependencies, so users who install these packages individually from npm receive fewer peer hints. +- The verifier cannot see imports injected by a tsdown `banner`, such as the inspector worker's `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`. Such a dependency stays only because it is declared; if it is removed by mistake, the verifier does not detect it. +- `readHostBuildEntries` imports the `tsdown.config.ts` of every covered package with a Host face and evaluates its default export under both build faces, so the gate depends on every such config module importing cleanly, and the modules' import side effects run in the gate process; for example, the inspector's config imports its package-local [`scripts/build-devtools.ts`](../../../../packages/experimental/inspector/scripts/build-devtools.ts). +- Measured: loading the 127 additional tsdown configurations takes about 2 s, and the full-run duration stays essentially the same (4:27 without the scan, 4:21 with it). + +## Testing + +[verify-package-dependencies.spec.ts](../../../../scripts/verify-package-dependencies.spec.ts) pins three cases: an experimental client package that declares a browser package as a peer is rejected, an experimental package listed in `hostPackages` is checked as a configured Host package, and a runtime dependency used only in a worker entry named by `tsdown.config.ts` stays in dependencies. The same spec pins the peer-required classification of translator's `TranslationError`. diff --git a/.agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.zh.md b/.agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.zh.md new file mode 100644 index 0000000000..346bc177ff --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-14-experimental-dependency-policy.zh.md @@ -0,0 +1,40 @@ +# Agent Note: 实验包遵守同一套依赖声明规则 + +Status: implemented + +[English](2026-10-14-experimental-dependency-policy.md) | 中文 + +## 问题 + +如果 [verify-package-dependencies](../../../../scripts/verify-package-dependencies.ts) 的检查范围是 `packages/!(experimental)/*/package.json`,整个 experimental 组就不受“浏览器和类型关系只放 devDependencies”([client 依赖规则](../../../../packages/client/AGENTS.md#dependency-declaration))的约束,[发布依赖面](2026-08-26-published-dependency-faces.zh.md)也曾把覆盖范围写成“非实验包”。在这一范围下,有 4 个实验 client 包把 client、api、session 等包写成 peerDependencies。webworker packer 会沿 workspace peer 把这些包收进镜像,浏览器静态库因此成了扫描起点,agent-team-profile 打包时出现 82 条无法解析的请求。[实验包规则](../../../../packages/experimental/AGENTS.md)写明实验身份不放宽工程要求,这一豁免与之矛盾。 + +按同一套规则检查 6 个带 `dsh.client` 的实验包,全量共 46 条违规,全部出在实验包。另外,只从 Host 入口追踪运行时导入的 verifier 会把 inspector 的 `ws` 误判为只该放 devDependencies,因为它只在 worker_threads 入口里使用。 + +## 决策 + +- 检查读取 `packages/*/*/package.json`,experimental 包与正式包遵守完全相同的规则,不设组级豁免,依赖区段检查也没有其他针对实验包的例外。 +- verifier 同时扫描包自己的 Host 侧 worker 和子进程入口,识别依据是包 `tsdown.config.ts` 声明的构建入口。`readHostBuildEntries` import 这份配置,在 `host` 和 `client` 两个构建 face(`DSH_BUILD_FACE`)下各求值一次默认导出并取并集,保留 platform 不是 browser、源码不在 `src/client/` 下的 entry。不靠猜源码写法。配置只能异步 import,所以 verifier 的状态读取是 async。 +- 受影响的实验包清单和 lockfile 符合策略;从 `peerDependencies` 或 `dependencies` 移走的每一项都确认过不是运行时必需。cot-translation 对 translator 的 `TranslationError` 做 `instanceof` 判断,需要同一个类实例,因此 `peerRequiredHostExports` 收录了这个导出。 +- 纯 Host 的实验包与正式包一样,只有列入 `hostPackages` 才受检查。是否扩大到全部纯 Host 包,另行决定。 +- 本决定部分取代[发布依赖面](2026-08-26-published-dependency-faces.zh.md)的选包覆盖范围,该 Note 的选包段落链接回本 Note;它的其余决定保持有效。 + +## 考虑过的替代方案 + +**只修这几个清单,不改检查范围。** 很快会再出现;规则没有执行点等于没有规则。 + +**在 packer 里跳过浏览器静态库这类扫描起点。** 只是绕开症状,错误的清单还会影响 npm 安装和其他消费方。 + +## 后果 + +`pnpm run verify-package-dependencies` 覆盖带 `dsh.client` 的实验包,它接受的清单让 webworker packer 能打包 agent-team-profile 和 voice-input-bundle,inspector 的发布清单也保留它的 worker 运行时依赖。 + +这一取舍的代价: + +- 发布的实验包清单只把浏览器和类型关系列为 dev 依赖,通过 npm 单独安装这些包的用户得到的 peer 提示更少。 +- verifier 看不到 tsdown `banner` 注入的导入,例如 inspector worker 的 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。这类依赖只因为已经声明而被保留;如果被误删,verifier 发现不了。 +- `readHostBuildEntries` 会 import 每个受覆盖、带 Host face 的包的 `tsdown.config.ts`,并在两个构建 face 下求值其默认导出,所以门禁依赖每个这样的配置模块都能干净地 import,这些模块的 import 副作用也会在门禁进程里执行;例如 inspector 的配置会 import 包内的 [`scripts/build-devtools.ts`](../../../../packages/experimental/inspector/scripts/build-devtools.ts)。 +- 实测:新增的 127 个 tsdown 配置加载约 2 s,全量耗时基本不变(不扫描时 4:27,扫描时 4:21)。 + +## 测试 + +[verify-package-dependencies.spec.ts](../../../../scripts/verify-package-dependencies.spec.ts) 锁定三种情形:实验 client 包把浏览器包写成 peer 时会被拒绝;列入 `hostPackages` 的实验包按 configured Host 包检查;只在 `tsdown.config.ts` 指定的 worker 入口里使用的运行时依赖保留在 dependencies。同一份 spec 还锁定 translator 的 `TranslationError` 属于 peer-required 分类。 diff --git a/.agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.i18n.yaml b/.agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.i18n.yaml new file mode 100644 index 0000000000..5cbae812c9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-14-no-top-level-await-in-packages.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-14-no-top-level-await-in-packages.md +/agent-note-package-sources-must-not-use-top-level-await: + en: d7409565b91b608c + zh: 855807c10ac7e44a +/agent-note-package-sources-must-not-use-top-level-await/problem: + en: 1735c60cec478b06 + zh: 85ed83b2b804b39e +/agent-note-package-sources-must-not-use-top-level-await/decision: + en: 7459c979b58be927 + zh: 569c68e1476f0129 +/agent-note-package-sources-must-not-use-top-level-await/alternatives-considered: + en: 5f19be0393f39abc + zh: ed0414b54eb17678 +/agent-note-package-sources-must-not-use-top-level-await/consequences: + en: d609ff0b1ce0c99a + zh: d60aa801ea72c2dd +/agent-note-package-sources-must-not-use-top-level-await/testing: + en: 55adbea10005be4b + zh: 8c95636d2a0fdad4 diff --git a/.agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.md b/.agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.md new file mode 100644 index 0000000000..bf7eaad485 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.md @@ -0,0 +1,45 @@ +# Agent Note: Package sources must not use top-level await + +Status: implemented + +English | [中文](2026-10-14-no-top-level-await-in-packages.zh.md) + +## Problem + +The child-process entry `src/worker.ts` of [speech-to-text-sensevoice](../../../../packages/experimental/speech-to-text-sensevoice/README.md) ran `await startRecognitionServer(...)` at module scope. Top-level await makes module evaluation asynchronous: an importer's evaluation waits on it, and an entry failure becomes a module-loading failure instead of an error the entry handles explicitly. The same file is also the package's `./worker` export, and the browser Preview's module loader wraps module bodies as CommonJS, so it refuses to pack top-level await, which kept voice-input-bundle out of the image. No repository gate prevented this pattern; a repository-wide scan found two more cases outside bins: ssh's `src/helper-entry.ts` (the target of the `./helper` export) and inspector's `src/worker/entry.ts` (a worker_threads entry). + +## Decision + +- Entry modules use an explicit `async function main()`: + - sensevoice: a failure before readiness writes only the error message to stderr and exits with code 1 once the message is flushed (`process.exit(1)` in the write callback, because a loaded native recognizer would otherwise keep the process alive); stdout carries only one `{"port":N}` line, which the Host parses as readiness. + - ssh helper: `main()` holds the helper's try/catch/finally and signal handling; every failure is reported on stderr with the `dsh-ssh-sandbox:` prefix and exit code 127. + - inspector worker: the synchronous startup stays at module scope, the asynchronous part runs in `main()`, and a failure sends the failure frame before stopping. +- Gate [verify-no-top-level-await](../../../../scripts/verify-no-top-level-await.ts): + - **Scope**: `.ts` and `.tsx` files under `packages/*/*/src/**`. + - **Rule**: based on the TypeScript AST. An `await` expression, `for await`, or `await using` whose nearest function body is the module is a violation, including occurrences in top-level if statements, blocks, try statements, loops, switch statements, conditional expressions, export initializers, and computed member names. Occurrences in functions, methods, constructors, accessors, parameter defaults, and class static blocks are not violations. + - **Exclusions**: only bins are excluded, judged from declarations and syntax alone: the source file starts with `#!`, or it is the `src/.ts|.tsx` that corresponds to a `lib/.js` named by package.json `bin`. A process executes a bin directly, and no other module imports it. The current exclusions are the `src/bin.ts` files of webworker-packer and llm-mock-server, and a spec pins the list. + - **Corpus guard**: the gate fails when the corpus is empty, contains no `.tsx`, or completely omits a `src/` directory that contains TS. + - **Registration**: a shared hygiene gate in `run-gates`, running in the static lane and the hygiene aggregate. The rule text lives in the [package-surface defensive patterns](../../../../docs/defensive-patterns/package-surface.md). + +## Alternatives considered + +**Rely only on the webworker packer's refusal.** The packer checks only modules that enter the image, so packages outside the image could still introduce top-level await. + +**Implement it as a lint rule.** oxlint has no existing rule that expresses "module scope" precisely. A syntax-level script gate is more controllable, and tests can pin every included and excluded form. + +**Forbid it in bins too.** A process executes a bin directly and nothing imports it, so its top-level await does not affect loading any module. + +## Consequences + +Every importable package source evaluates synchronously, including packages that never enter the Preview image, and each entry handles its own startup failure; voice-input-bundle enters the Preview image. + +The trade-off costs: + +- Third-party packages and `apps/` are outside the scope; the page bootstrap script `apps/web/src/preview.ts` uses top-level await. +- sensevoice startup failure diagnostics carry only the error message, without a stack trace. + +## Testing + +- [verify-no-top-level-await.spec.ts](../../../../scripts/verify-no-top-level-await.spec.ts) pins every included and excluded form, the corpus guards, and the current exclusion list; [run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) pins the gate's registration. With the asynchronous startup of the three entries above at module scope, the gate reports 4 cases (1 in sensevoice, 1 in ssh, 2 in inspector) and fails. +- [worker-entry.spec.ts](../../../../packages/experimental/speech-to-text-sensevoice/tests/worker-entry.spec.ts) launches the sensevoice entry and pins that a failure before readiness exits with code 1, leaves stdout empty, and reports the reason on stderr without a stack trace. +- [helper-boundaries.spec.ts](../../../../packages/ssh/ssh/tests/helper-boundaries.spec.ts) launches the ssh helper entry, and [integration.host.spec.ts](../../../../packages/experimental/inspector/tests/integration.host.spec.ts) starts the inspector Worker through `startInspector`; no spec drives the inspector worker entry's startup-failure frame. diff --git a/.agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.zh.md b/.agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.zh.md new file mode 100644 index 0000000000..7aa75cd3ba --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-14-no-top-level-await-in-packages.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 包源码禁止顶层 await + +Status: implemented + +[English](2026-10-14-no-top-level-await-in-packages.md) | 中文 + +## 问题 + +[speech-to-text-sensevoice](../../../../packages/experimental/speech-to-text-sensevoice/README.zh.md) 的子进程入口 `src/worker.ts` 曾在模块顶层 `await startRecognitionServer(...)`。顶层 await 让模块求值变成异步:导入方的求值被它挂住,入口的失败也变成模块加载失败,而不是入口自己显式处理的错误。这个文件同时是包的 `./worker` 导出,浏览器 Preview 的模块加载器按 CommonJS 包装模块体,遇到顶层 await 会拒绝打包,voice-input-bundle 因此无法进入镜像。仓库里当时没有任何门禁阻止这种写法;全仓扫描时,除 bin 外还有另外两处:ssh 的 `src/helper-entry.ts`(`./helper` 导出的目标)和 inspector 的 `src/worker/entry.ts`(worker_threads 入口)。 + +## 决策 + +- 入口模块用显式 `async function main()`: + - sensevoice:就绪前失败时只把错误消息写到 stderr,消息写完后以退出码 1 退出(在写入回调里调用 `process.exit(1)`,否则已加载的原生识别器会让进程一直存活);stdout 只有一行 `{"port":N}`,Host 把它解析为就绪信号。 + - ssh helper:`main()` 承载 helper 的 try/catch/finally 和信号处理;每个失败都以 `dsh-ssh-sandbox:` 前缀写到 stderr,退出码为 127。 + - inspector worker:同步启动部分留在模块顶层,异步部分在 `main()` 里运行,失败时先发 failure 帧再 stop。 +- 门禁 [verify-no-top-level-await](../../../../scripts/verify-no-top-level-await.ts): + - **范围**:`packages/*/*/src/**` 下的 `.ts` 和 `.tsx`。 + - **判定**:基于 TypeScript AST。最近的函数体是模块本身的 `await` 表达式、`for await`、`await using` 都算违规,包括顶层 if、块、try、循环、switch、条件表达式、export 初始化和计算成员名里的。函数、方法、构造器、访问器、参数默认值、class static block 里的不算。 + - **排除**:只排除 bin,依据只取声明和语法:源文件以 `#!` 开头,或者是 package.json `bin` 指向的 `lib/.js` 对应的 `src/.ts|.tsx`。bin 由进程直接执行,不会被其他模块导入。当前排除 webworker-packer 和 llm-mock-server 的两个 `src/bin.ts`,清单由 spec 锁定。 + - **语料防护**:语料为空、没有 `.tsx`、或某个含 TS 的 `src/` 目录完全没进语料时,门禁报错。 + - **注册**:`run-gates` 的共享卫生门禁,跑在 static lane 和 hygiene 聚合里。规则正文写在 [package-surface 防御模式](../../../../docs/defensive-patterns/package-surface.zh.md)。 + +## 考虑过的替代方案 + +**只靠 webworker packer 的拒绝来把关。** packer 只检查进入镜像的模块,镜像外的包仍可能引入顶层 await。 + +**用 lint 规则实现。** oxlint 没有现成规则能精确表达“模块顶层”。语法级脚本门禁更可控,也能用测试锁定每一种纳入和排除的形式。 + +**连 bin 一起禁止。** bin 由进程直接执行,不被导入,顶层 await 不影响任何模块的加载。 + +## 后果 + +每个可被导入的包源码都同步求值,包括从不进入 Preview 镜像的包;每个入口自己处理启动失败;voice-input-bundle 能进入 Preview 镜像。 + +这一取舍的代价: + +- 第三方包和 `apps/` 不在范围内;页面引导脚本 `apps/web/src/preview.ts` 使用顶层 await。 +- sensevoice 启动失败的诊断只有错误消息,不带堆栈。 + +## 测试 + +- [verify-no-top-level-await.spec.ts](../../../../scripts/verify-no-top-level-await.spec.ts) 锁定每一种纳入和排除的形式、语料防护和当前的排除清单;[run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 锁定门禁的注册。若上述三个入口的异步启动放在模块顶层,门禁报出 4 处(sensevoice 1 处、ssh 1 处、inspector 2 处)并失败。 +- [worker-entry.spec.ts](../../../../packages/experimental/speech-to-text-sensevoice/tests/worker-entry.spec.ts) 启动 sensevoice 入口,锁定就绪前失败时退出码为 1、stdout 为空、stderr 给出原因且不带堆栈。 +- [helper-boundaries.spec.ts](../../../../packages/ssh/ssh/tests/helper-boundaries.spec.ts) 启动 ssh helper 入口,[integration.host.spec.ts](../../../../packages/experimental/inspector/tests/integration.host.spec.ts) 经 `startInspector` 启动 inspector Worker;没有 spec 触发 inspector worker 入口的启动失败帧。 diff --git a/.agents/notes/implemented/process/2026-10-15-node-24-runtime-floor.i18n.yaml b/.agents/notes/implemented/process/2026-10-15-node-24-runtime-floor.i18n.yaml new file mode 100644 index 0000000000..16e75091eb --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-15-node-24-runtime-floor.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-15-node-24-runtime-floor.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-15-node-24-runtime-floor.md +/agent-note-node-runtime-floor-and-compatibility-coverage: + en: 256156264ba84f11 + zh: 29680265e82fc2c6 +/agent-note-node-runtime-floor-and-compatibility-coverage/problem: + en: 1e45546d7452029f + zh: d99966ac42bb193f +/agent-note-node-runtime-floor-and-compatibility-coverage/decision: + en: 13b84da1ca9bbb1f + zh: 5bf45cf6f742cd5a +/agent-note-node-runtime-floor-and-compatibility-coverage/consequences: + en: 224ce4b13f297f8b + zh: 1bb5f8ebdf3243b3 +/agent-note-node-runtime-floor-and-compatibility-coverage/alternatives-considered: + en: 413d1133689bd55c + zh: fc77301991e1f425 diff --git a/.agents/notes/implemented/process/2026-10-15-node-24-runtime-floor.md b/.agents/notes/implemented/process/2026-10-15-node-24-runtime-floor.md new file mode 100644 index 0000000000..b43845d147 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-15-node-24-runtime-floor.md @@ -0,0 +1,33 @@ +# Agent Note: Node runtime floor and compatibility coverage + +Status: implemented + +English | [中文](2026-10-15-node-24-runtime-floor.zh.md) + +## Problem + +The root `engines.node` range promises that the installed workspace supports each admitted runtime. Its minimum must cover both the runtime APIs DSH uses and the declared requirements of installed dependencies. Otherwise strict installation can reject a supported version before DSH starts. + +## Decision + +Set `engines.node` to `>=24.9.0`. Node 24.9 is the minimum for DSH and its development carriers, including the experimental OAR subagent integration, whose `@botiverse/oar` library requires Node 24. This supersedes the [Node 22.19 floor](../../archived/process/2026-07-06-node-engine-floor.md); its Node 23 exclusion remains satisfied, and the declaration policy below remains narrower than the runtime floor. Migration instructions live in the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/node-24/guide.md). + +The primary Node 24 jobs own complete typechecking and unit coverage. Focused compatibility jobs run on pinned Node 24.9 and Node 26. Node 24.9 supports the existing `import.meta.main` script entrypoints and native TypeScript stripping. Pinning 24.9 exercises this verified minimum and its v1 private module loader; the primary jobs track the latest Node 24 and exercise its v2 loader, introduced in 24.12.0. A floating `24` compatibility job would duplicate the primary loader coverage and stop testing the minimum. + +The Node 24.9 compatibility job also builds artifacts and checks that the shipped Web CLI starts and disposes with full-text Session search disabled. Command-entrypoint, source-worker, Zstandard, source-launch, profile-resolution, and jsdom-storage checks run on both compatibility versions. Real-API e2e jobs stay on Node 24 because they validate provider integration. + +The root, Web, and desktop projects retain `@types/node` on the 22.x line. Their current code needs no Node 24-only declarations. A narrower type API remains valid on the supported runtime and avoids unrelated declaration changes. Raise these declarations when a supported API requires them, and keep their major no newer than the runtime floor. The independently published native addon has its own runtime and type support policy. + +## Consequences + +- Node 22 installations must upgrade before running DSH. Packaged applications with an embedded runtime do not require a system Node upgrade. +- Supported runtimes provide `node:sqlite` and native TypeScript stripping without opt-in flags. The source CLI still uses its declared `tsx/esm` launcher. +- The compatibility graph retains both private loader interfaces used by supported Node versions. +- A future runtime-floor change updates `engines.node`, compatibility jobs, current documentation, this note, and an upgrade guide together. + +## Alternatives considered + +- **Keep Node 22 support.** It excludes the OAR library from the supported installation. +- **Use OAR’s declared minimum of Node 24.0.** Existing script entrypoints depend on `import.meta.main`, which Node 24.0 lacks. A dependency minimum alone does not establish the application minimum. +- **Test only the latest Node 24.** It does not exercise the minimum or the older private loader interface still used within the supported 24.x line. +- **Raise Node declarations ahead of the minimum.** It allows code to compile against APIs that a supported runtime can lack. diff --git a/.agents/notes/implemented/process/2026-10-15-node-24-runtime-floor.zh.md b/.agents/notes/implemented/process/2026-10-15-node-24-runtime-floor.zh.md new file mode 100644 index 0000000000..60fe3ac97a --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-15-node-24-runtime-floor.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Node 运行时下限与兼容性覆盖 + +Status: implemented + +[English](2026-10-15-node-24-runtime-floor.md) | 中文 + +## 问题 + +根 `engines.node` 范围承诺安装后的工作区支持其允许的每一个运行时。其下限必须覆盖 DSH 使用的运行时 API 和所安装依赖声明的要求。否则,严格安装可能会在 DSH 启动前拒绝一个受支持的版本。 + +## 决策 + +将 `engines.node` 设为 `>=24.9.0`。Node 24.9 是 DSH 及其开发载体的下限,也允许实验性 OAR 子代理集成使用要求 Node 24 的 `@botiverse/oar` 库。此决策取代 [Node 22.19 下限](../../archived/process/2026-07-06-node-engine-floor.md);Node 23 仍被排除,下文的声明策略也仍比运行时下限更窄。迁移说明见[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/node-24/guide.zh.md)。 + +主要的 Node 24 任务负责完整的类型检查和单元测试覆盖率。聚焦的兼容性任务在固定的 Node 24.9 和 Node 26 上运行。Node 24.9 支持现有 `import.meta.main` 脚本入口和原生 TypeScript 类型剥离。固定 24.9 能验证这个已验证的下限及其 v1 私有模块加载器;主要任务跟随最新的 Node 24,验证 24.12.0 引入的 v2 加载器。浮动的 `24` 兼容性任务会重复主要任务的加载器覆盖,并停止验证下限。 + +Node 24.9 兼容性任务还构建产物,并检查随产品提供的 Web CLI 能在禁用全文 Session 搜索时启动和释放资源。command-entrypoint、source-worker、Zstandard、source-launch、profile-resolution 和 jsdom-storage 检查在两个兼容性版本上运行。真实 API 的 e2e 任务保留在 Node 24 上,因为它们验证的是提供方集成。 + +根项目、Web 和桌面项目将 `@types/node` 保留在 22.x 线。它们当前的代码不需要 Node 24 专属声明。较窄的类型 API 仍适用于受支持的运行时,也避免无关的声明变更。当受支持的 API 需要时再提升这些声明,并确保其主版本不高于运行时下限。独立发布的原生 addon 有自己的运行时和类型支持策略。 + +## 后果 + +- 使用 Node 22 的安装必须先升级,才能运行 DSH。内嵌运行时的打包应用不要求升级系统 Node。 +- 受支持的运行时无需启用标志即可使用 `node:sqlite` 和原生 TypeScript 类型剥离。源码 CLI 仍使用声明的 `tsx/esm` 启动器。 +- 兼容性任务图保留受支持 Node 版本使用的两种私有加载器接口。 +- 后续提升运行时下限时,必须一起更新 `engines.node`、兼容性任务、当前文档、本文和升级指南。 + +## 考虑过的替代方案 + +- **保留 Node 22 支持。** 这会将 OAR 库排除在受支持的安装之外。 +- **采用 OAR 声明的 Node 24.0 下限。** 现有脚本入口依赖 `import.meta.main`,而 Node 24.0 缺少该功能。仅凭依赖下限无法确定应用下限。 +- **只验证最新的 Node 24。** 这既不能验证下限,也不能验证受支持 24.x 系列仍使用的旧私有加载器接口。 +- **将 Node 声明提升至下限之上。** 这会允许代码使用受支持运行时可能缺少的 API 编译通过。 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 deleted file mode 100644 index 4aad229581..0000000000 --- a/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.i18n.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# 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/simplification/2026-10-18-borrow-auto-review-inputs.i18n.yaml b/.agents/notes/proposed/simplification/2026-10-18-borrow-auto-review-inputs.i18n.yaml new file mode 100644 index 0000000000..78586a693c --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-10-18-borrow-auto-review-inputs.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-18-borrow-auto-review-inputs.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/simplification/2026-10-18-borrow-auto-review-inputs.md +/agent-note-borrow-auto-review-inputs-until-serialization: + en: 7935a9077525c95c + zh: f54640700003b6a3 +/agent-note-borrow-auto-review-inputs-until-serialization/problem: + en: ae55e01074cf34e0 + zh: c9778fdb8fb157e3 +/agent-note-borrow-auto-review-inputs-until-serialization/proposal: + en: 67b001f13952f165 + zh: 09f849fad8817aad +/agent-note-borrow-auto-review-inputs-until-serialization/alternatives-considered: + en: 86495afdbc23ebe1 + zh: d5c237aea3f14eed +/agent-note-borrow-auto-review-inputs-until-serialization/acceptance-criteria: + en: b193bda5eb966372 + zh: e493c784adec4f05 +/agent-note-borrow-auto-review-inputs-until-serialization/risks: + en: df5aebcbd42a13df + zh: 5945aa631bd4e07f diff --git a/.agents/notes/proposed/simplification/2026-10-18-borrow-auto-review-inputs.md b/.agents/notes/proposed/simplification/2026-10-18-borrow-auto-review-inputs.md new file mode 100644 index 0000000000..f2dd75e13c --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-10-18-borrow-auto-review-inputs.md @@ -0,0 +1,25 @@ +# Agent Note: Borrow Auto review inputs until serialization + +Status: proposed + +English | [中文](2026-10-18-borrow-auto-review-inputs.zh.md) + +## Problem + +[`snapshotAutoReview()`](../../../../packages/experimental/auto-review/src/index.ts) reads frozen Session history, request headers, and tool arguments, but also copies `session.surface.nodes` and freezes its private result. Its only caller, `classifyRisk()`, immediately serializes that result without an intervening await or publication. + +## Proposal + +Borrow `session.surface.nodes` and return the readonly intermediate result without another `deepFreeze()`. Preserve all action validation and the final `GenerateOptions` freeze. [Session-owned immutability](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) and [request-only input freezing](../../implemented/architecture/2026-09-17-persistence-attribution-policy.md) remain unchanged. This removes a private array allocation and freeze traversal without changing the reviewer request. + +## Alternatives considered + +**Keep the intermediate snapshot.** A future retained or asynchronous reader would need stable inputs. No current consumer retains this object or awaits before rendering. + +## Acceptance criteria + +Native and PTC requests retain identical text, source filtering, and action checks. Existing request-freezing and cancellation assertions pass. Serialization does not mutate borrowed Session or execution data. + +## Risks + +Future asynchronous work or publication of the intermediate object would require reconsidering ownership. Removing the final request freeze or the source owners' snapshots is outside this proposal. diff --git a/.agents/notes/proposed/simplification/2026-10-18-borrow-auto-review-inputs.zh.md b/.agents/notes/proposed/simplification/2026-10-18-borrow-auto-review-inputs.zh.md new file mode 100644 index 0000000000..6ba7bf4aa0 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-10-18-borrow-auto-review-inputs.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Auto review 输入在序列化前采用借用 + +Status: proposed + +[English](2026-10-18-borrow-auto-review-inputs.md) | 中文 + +## Problem + +[`snapshotAutoReview()`](../../../../packages/experimental/auto-review/src/index.ts) 读取已冻结的 Session 历史、请求头和工具参数,但还会复制 `session.surface.nodes` 并冻结私有结果。它唯一的调用方 `classifyRisk()` 会立即序列化该结果,中间没有 await,也不会发布结果。 + +## Proposal + +借用 `session.surface.nodes`,返回只读的中间结果,不再调用一次 `deepFreeze()`。保留全部操作校验和最终 `GenerateOptions` 的冻结。[Session 拥有的不可变性](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md) 和[仅用于请求的输入冻结](../../implemented/architecture/2026-09-17-persistence-attribution-policy.zh.md) 保持不变。这会移除一次私有数组分配和冻结遍历,同时保持审核请求不变。 + +## Alternatives considered + +**保留中间快照。** 未来若有留存数据或异步读取的消费方,就需要稳定输入。当前没有消费方留存该对象,也没有消费方在渲染前执行 await。 + +## Acceptance criteria + +原生工具和 PTC 请求保留完全相同的文本、来源过滤和操作检查。现有请求冻结与取消断言通过。序列化不修改借用的 Session 或执行数据。 + +## Risks + +未来若引入异步工作或发布中间对象,需要重新考虑所有权。移除最终请求的冻结或数据所有者提供的快照不在本提案范围内。 diff --git a/.agents/notes/proposed/simplification/2026-10-18-parse-ssh-policy-once.i18n.yaml b/.agents/notes/proposed/simplification/2026-10-18-parse-ssh-policy-once.i18n.yaml new file mode 100644 index 0000000000..841d99eb8a --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-10-18-parse-ssh-policy-once.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-18-parse-ssh-policy-once.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/simplification/2026-10-18-parse-ssh-policy-once.md +/agent-note-parse-ssh-policies-once-at-request-admission: + en: 0c215d7a2d291857 + zh: df3c91383d122974 +/agent-note-parse-ssh-policies-once-at-request-admission/problem: + en: 3cb8bcfb464726d4 + zh: f87f7cc23e7d161c +/agent-note-parse-ssh-policies-once-at-request-admission/proposal: + en: 71eb2e823861a3b0 + zh: 0f6fb08df9b3217f +/agent-note-parse-ssh-policies-once-at-request-admission/alternatives-considered: + en: 98230674c8b68772 + zh: 068c70ede9914a05 +/agent-note-parse-ssh-policies-once-at-request-admission/acceptance-criteria: + en: e48ed6ba31628951 + zh: 240022dd565487bc +/agent-note-parse-ssh-policies-once-at-request-admission/risks: + en: 0d2e4cd1658f0d1a + zh: 6838c9083b629c8b diff --git a/.agents/notes/proposed/simplification/2026-10-18-parse-ssh-policy-once.md b/.agents/notes/proposed/simplification/2026-10-18-parse-ssh-policy-once.md new file mode 100644 index 0000000000..0074e7fb5e --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-10-18-parse-ssh-policy-once.md @@ -0,0 +1,25 @@ +# Agent Note: Parse SSH policies once at request admission + +Status: proposed + +English | [中文](2026-10-18-parse-ssh-policy-once.zh.md) + +## Problem + +The [`sandbox` and `fs.write`/`fs.edit` handlers](../../../../packages/ssh/ssh/src/helper.ts) are the private policy resolver's only callers. They validate requests with `policySchema`; the resolver repeats that parse, including a policy and extra-root array copy. + +## Proposal + +Accept `z.output` in the private resolver and remove its parse. Retain both wire parsers, execution-host path resolution, cancellation, SessionId branding, and explicit omission of undefined optional fields. The [SSH execution decision](../../implemented/architecture/2026-09-11-posix-ssh-runtime.md) continues to own remote policy enforcement. + +## Alternatives considered + +**Keep the resolver's unknown input.** It protects future unparsed callers, but current callers already decode. A typed parameter states the required precondition without repeated runtime work. + +## Acceptance criteria + +Both request handlers still reject malformed policies, relative roots, and unexpected fields. Remote physical-root checks and returned policies remain unchanged. Existing helper tests cover absent and supplied roots, read-only policy, and denied writes. + +## Risks + +A future wire caller must validate before invoking the resolver. Preserve optional-field omission because Zod permits explicit `undefined` while the execution-policy type does not. diff --git a/.agents/notes/proposed/simplification/2026-10-18-parse-ssh-policy-once.zh.md b/.agents/notes/proposed/simplification/2026-10-18-parse-ssh-policy-once.zh.md new file mode 100644 index 0000000000..c1a300c6c8 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-10-18-parse-ssh-policy-once.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 在请求接收处只解析一次 SSH 策略 + +Status: proposed + +[English](2026-10-18-parse-ssh-policy-once.md) | 中文 + +## Problem + +[`sandbox` 和 `fs.write`/`fs.edit` 处理函数](../../../../packages/ssh/ssh/src/helper.ts) 是私有策略解析器仅有的调用方。它们使用 `policySchema` 校验请求;解析器重复该解析,包括复制策略对象和额外根目录数组。 + +## Proposal + +让私有解析器接收 `z.output`,移除其中的解析。保留两个线上请求解析器、执行主机上的路径解析、取消、SessionId 品牌类型转换,以及对值为 undefined 的可选字段的显式省略。[SSH 执行决策](../../implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md) 继续规定远程策略执行。 + +## Alternatives considered + +**保留解析器的 unknown 输入。** 它能保护未来未经解析的调用方,但当前调用方已经解码。类型化参数可表达必要前提,无需重复执行运行时工作。 + +## Acceptance criteria + +两个请求处理函数仍拒绝格式错误的策略、相对根目录和意外字段。远程物理根目录检查和返回策略保持不变。现有辅助进程测试覆盖未提供和已提供根目录、只读策略以及被拒绝的写入。 + +## Risks + +未来的线上请求调用方必须先校验,再调用解析器。保留可选字段省略,因为 Zod 允许显式 `undefined`,而执行策略类型不允许。 diff --git a/.agents/notes/proposed/simplification/2026-10-18-single-workspace-canonicalization.i18n.yaml b/.agents/notes/proposed/simplification/2026-10-18-single-workspace-canonicalization.i18n.yaml new file mode 100644 index 0000000000..cead59c4e0 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-10-18-single-workspace-canonicalization.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-18-single-workspace-canonicalization.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/simplification/2026-10-18-single-workspace-canonicalization.md +/agent-note-canonicalize-each-sandbox-workspace-once: + en: d9beb1e7a988d598 + zh: 9ff16ea850cb3d57 +/agent-note-canonicalize-each-sandbox-workspace-once/problem: + en: 04219da3d9301a2d + zh: 92a399597d315cba +/agent-note-canonicalize-each-sandbox-workspace-once/proposal: + en: d5017ea68bf366ea + zh: 6aa157b402d4d8d0 +/agent-note-canonicalize-each-sandbox-workspace-once/alternatives-considered: + en: f73a3a3a1a90fb16 + zh: 3ec1d23f24436e26 +/agent-note-canonicalize-each-sandbox-workspace-once/acceptance-criteria: + en: 0a2aa37511fa5574 + zh: 15258e58b498d944 +/agent-note-canonicalize-each-sandbox-workspace-once/risks: + en: 1fdb53b7c48b1f8d + zh: 999debc230df8d39 diff --git a/.agents/notes/proposed/simplification/2026-10-18-single-workspace-canonicalization.md b/.agents/notes/proposed/simplification/2026-10-18-single-workspace-canonicalization.md new file mode 100644 index 0000000000..ac65eb7791 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-10-18-single-workspace-canonicalization.md @@ -0,0 +1,25 @@ +# Agent Note: Canonicalize each sandbox workspace once + +Status: proposed + +English | [中文](2026-10-18-single-workspace-canonicalization.zh.md) + +## Problem + +[`LocalSandboxProvider.confine()`](../../../../packages/sandbox/sandbox-local/src/index.ts) canonicalizes `workspaceRoot` and copies the policy before dispatch. POSIX profile builders call [`workspaceWritableRoots()`](../../../../packages/sandbox/sandbox/src/roots.ts), which resolves that workspace again. The common preprocessing also runs for POSIX read-only policies that grant no workspace writes. + +## Proposal + +Remove the common policy copy. Keep canonicalization in shared POSIX root derivation; canonicalize once inside `windowsAclRunnerArgv()`, reusing that value for argv, SID derivation, and ACL grants. Preserve per-call extra-root validation and each backend's temporary-directory rules. + +## Alternatives considered + +**Keep common preprocessing.** It supplies every backend with one physical workspace identity, but POSIX repeats the filesystem work. Explicit Windows normalization preserves the [Windows ACL decision](../../implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md). + +## Acceptance criteria + +Direct profile builders and `runnerCommand` retain canonical roots. Windows argv, SID, and grant keys agree. Existing alias-rejection and read-only checks pass; a focused trace confirms removal of the duplicate workspace lookup. + +## Risks + +Incomplete relocation can split Windows grant identity. Do not cache extra roots: enforcement must recheck a directory replaced by a symlink. diff --git a/.agents/notes/proposed/simplification/2026-10-18-single-workspace-canonicalization.zh.md b/.agents/notes/proposed/simplification/2026-10-18-single-workspace-canonicalization.zh.md new file mode 100644 index 0000000000..559e2d6109 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-10-18-single-workspace-canonicalization.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 每个沙箱工作区只规范化一次 + +Status: proposed + +[English](2026-10-18-single-workspace-canonicalization.md) | 中文 + +## Problem + +[`LocalSandboxProvider.confine()`](../../../../packages/sandbox/sandbox-local/src/index.ts) 在分派前规范化 `workspaceRoot` 并复制策略。POSIX 配置构建函数调用 [`workspaceWritableRoots()`](../../../../packages/sandbox/sandbox/src/roots.ts),再次解析同一工作区。对于不授予工作区写权限的 POSIX 只读策略,公共预处理也会执行。 + +## Proposal + +移除公共策略复制。保留共享 POSIX 根目录推导中的规范化;在 `windowsAclRunnerArgv()` 内规范化一次,并在 argv、SID 推导和 ACL 授权中复用该值。保留每次调用的额外根目录校验,以及各后端的临时目录规则。 + +## Alternatives considered + +**保留公共预处理。** 它为每个后端提供统一的物理工作区标识,但 POSIX 会重复访问文件系统。显式保留 Windows 规范化可维持 [Windows ACL 决策](../../implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md)。 + +## Acceptance criteria + +直接调用配置构建函数和使用 `runnerCommand` 时仍得到规范化的根目录。Windows argv、SID 与授权键保持一致。现有别名拒绝和只读检查通过;聚焦的跟踪结果确认重复的工作区查找已移除。 + +## Risks + +迁移不完整可能使 Windows 授权标识不一致。不得缓存额外根目录:执行权限限制时必须重新检查被符号链接替换的目录。 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index ab9a40ab32..7433bde20d 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml @@ -9,8 +9,8 @@ en: 7b7248fc12313b69 zh: a55e2c3376c401a7 /agent-note-dependency-swaps-rejected-by-the-2026-07-nih-audit/proposal: - en: d95debbdb2a7e730 - zh: 1edbf6ca992a2319 + en: fcd5edbdfefd224c + zh: 0172065e20f9b3ee /agent-note-dependency-swaps-rejected-by-the-2026-07-nih-audit/alternatives-considered: en: 229a0110ca47384e zh: 3f86f858d56190c5 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index aa95cee133..9eb4d17f30 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -58,7 +58,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu **SQLite and storage:** -- **`better-sqlite3` for the three SQLite backends**: all use builtin `node:sqlite`, intentional twice over — it gates the [Node engine floor](../../implemented/process/2026-07-06-node-engine-floor.md) and works inside the single-file executable where a native addon would complicate packaging. No hand-rolled migrations or busy-retry loops exist. +- **`better-sqlite3` for the three SQLite backends**: all use builtin `node:sqlite`, intentional twice over — it is available throughout the [supported Node range](../../implemented/process/2026-10-15-node-24-runtime-floor.md) and works inside the single-file executable where a native addon would complicate packaging. No hand-rolled migrations or busy-retry loops exist. **Repo tooling:** diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index 943753a5b4..6d11bb018b 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -58,7 +58,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 **SQLite 与存储:** -- **以 `better-sqlite3` 承担三个 SQLite 后端**:三者全部使用内置 `node:sqlite`,且是双重有意为之——它是 [Node 引擎下限](../../implemented/process/2026-07-06-node-engine-floor.zh.md)的把关依据,也能在单文件可执行体内工作,原生 addon 反而会让打包复杂化。不存在任何手写的迁移或 busy 重试循环。 +- **以 `better-sqlite3` 承担三个 SQLite 后端**:三者全部使用内置 `node:sqlite`,且是双重有意为之——它在 [受支持的 Node 范围](../../implemented/process/2026-10-15-node-24-runtime-floor.zh.md)内可用,也能在单文件可执行体内工作,原生 addon 反而会让打包复杂化。不存在任何手写的迁移或 busy 重试循环。 **仓库工具链:** diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 2dd48bda56..9b0b48bc57 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -41,9 +41,9 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - **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. - **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. +- **Scope, ownership, and necessity:** first identify the required behavior behind each new distinction, field, adapter, state machine, option, defensive copy, and compatibility path. A requirement, check, or Agent Note introduced by the PR is a design choice to assess, not independent evidence of necessity. Compare existing source fields, discriminants, and their consumers before accepting another representation; distinguish deleting a concept from relocating its declaration. Moving an unnecessary concept closer to its owner is not a simplification. Challenge unrelated features and speculative generality against [the root rules](../../../AGENTS.md#conventions). +- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package rules](../../../packages/AGENTS.md). For required behavior, prefer an existing extension point near its producer or consumer when the local implementation adds little complexity, following [the ownership rule](../../../docs/defensive-patterns/general.md#a-business-capability-owns-itself); compare producer-owned declaration merging with changes to distant core or shared type declarations. 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. +- **Configuration and public choices:** trace authoritative reads and precedence before accepting another CLI or configuration pass-through; a forwarded value may be shadowed before use. 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. - **Model perspective:** inspect the exact prompts, tool schemas, results, and diagnostics the model receives across affected modes. Flag concepts outside the model's task, then verify stable text verbatim and dynamic behavior through snapshots or end-to-end coverage. - **Enforcement:** follow every denial path to the operation that executes it; exercise direct and alternate callers that can bypass schemas, prompts, facades, wrappers, or listener ordering. - **Borrowed and derived state:** determine whether each retained value is borrowed or owned under the package contract, then trace notifications and every cache, prompt, UI echo, replay, and query view to the documented success point and authoritative source. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 46955d8abd..d1ad560b81 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -17,14 +17,14 @@ For broad requests, divide independent domains among subagents: loop and persist ## Search for removable obligations -Use these questions to guide discovery. Read the relevant section of [historical patterns](references/historical-patterns.md) when a candidate needs calibration; the examples illustrate decisions, not a current deletion inventory. +Use these questions to guide discovery. Requirements, checks, and Agent Notes introduced by the code under review are design choices to assess, not independent evidence that its distinctions are necessary. Read the relevant section of [historical patterns](references/historical-patterns.md) when a candidate needs calibration; the examples illustrate decisions, not a current deletion inventory. -- **Does a declared feature have a complete effect path?** Trace producer, transformations, provider or endpoint support, and observable result. A field copied everywhere may still have no setter, no reader, or only implementations that reject it. Search constructors and discriminant emitters as well as callers. -- **Which distinctions change a consumer's action?** Several internal states may require one model-facing status or one usable identifier. Keep internal distinctions that control authorization, residency, durability, or ownership; remove public distinctions that only invite an unusable choice. +- **Does a declared feature have a complete effect path?** Trace producer, transformations, provider or endpoint support, and observable result. A field copied everywhere may still have no setter, no reader, or only implementations that reject it. For CLI and configuration pass-throughs, follow authoritative reads and precedence to find forwarded values that are shadowed before use. Search constructors and discriminant emitters as well as callers. +- **Which distinctions change a consumer's action?** First ask whether the distinction, field, or adapter is needed at all. Trace existing source fields and discriminants through their emitters and authorization or capability checks before adding a transport-specific marker; a broad envelope category need not have the source field's meaning. Consider deleting a distinction separately from relocating its declaration, and state which inputs or capabilities each alternative admits. Several internal states may require one model-facing status or one usable identifier. Preserve required authorization, residency, durability, and ownership behavior; remove public distinctions that only invite an unusable choice. - **Could a smaller explicit behavior remove the subsystem?** Fixed intervals, caller-supplied time zones, or reported activation failure can eliminate generalized calendars, persisted defaults, or automatic rollback. Name the capability given up and the machinery deleted. A production caller makes this a behavior decision, not an automatic rejection; implementation must remain within the user's authorization. - **Can a consumer read the authoritative value when needed?** Look for copied histories, promotion ledgers, invalidation events followed by reads, and feature caches beside a shared projection stream. Derivation or reuse may delete subscriptions, retained data, stale-response handling, and recovery paths together. Establish the required time of observation first. - **Is composition being mistaken for policy?** Optional service presence, a provider method, or a sibling tool can express availability without deciding deployment behavior. Explicit configuration or a caller-intent operation may remove registries and dependency checks. Preserve distinct operations when their ownership or completion timing differs. -- **What owns the complete maintenance cost?** Trace pass-through configuration, duplicate application trees, demo launchers used by products, resolver-only workspaces, and installation helpers with their own upgrade lifecycle. Compare the entire removed system with the replacement, including residual glue; moving complexity or adding a gate to keep duplicate definitions equal does not remove it. +- **What owns the complete maintenance cost?** Trace duplicate application trees, demo launchers used by products, resolver-only workspaces, and installation helpers with their own upgrade lifecycle. For required behavior, compare existing producer/consumer extension points, including declaration merging, with changes to distant core or shared type declarations under [the ownership rule](../../../docs/defensive-patterns/general.md#a-business-capability-owns-itself). Compare the entire removed system with the replacement, including residual glue; moving an unnecessary concept closer to its owner or adding a gate to keep duplicate definitions equal does not remove its maintenance cost. ## Prove reachability and the trade-off @@ -32,7 +32,7 @@ Start with `rg`, then read the matches. Search exact symbols, property reads and Include `packages/*/*/src`, `apps/`, `python/`, runtime scripts, shipped profiles and overlays, package manifests and exports, loader resolution, generated runtime assets, and installed-package consumers. Classify examples and fixtures by their actual entry point rather than directory name. Follow generated catalogs to their consumers: a catalog used by model-written plugin mounts is a dynamic product API, while a documentation-only listing is not a fixed caller. Public third-party extension paths can be intentional despite zero repository call sites. A static unused-code checker must cover manifest-driven loading, generated outputs, and compiler faces without maintaining a parallel per-workspace exception inventory. -For each candidate, record the current owner, effective producer/consumer path, what disappears, what remains, and the strongest reason to retain it. Distinguish: +For each candidate, record the current owner, effective producer/consumer path, what disappears, what remains, capability and compatibility effects, and the strongest reason to retain it. Distinguish: - removal of unreachable or unread behavior; - a narrower public or product behavior with an explicit loss; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93c3d5293c..0331b5b6ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -415,13 +415,8 @@ jobs: max-parallel: 1 matrix: include: - - node: '22.19' - name: node 22.19 - runner: ubuntu-latest - gate_concurrency: '1' - # Pinned inside 24.0-24.11.1: those releases carry the v1 internal - # loader while reporting major 24, and every other job tracks the - # latest 24, which is v2. A bare `24` here would retest that same v2. + # Pin the supported floor, whose internal loader is v1. Primary + # jobs track latest 24 and exercise v2, introduced in 24.12.0. - node: '24.9' name: node 24.9 runner: ubuntu-latest @@ -572,6 +567,15 @@ jobs: - name: Run blocking Windows builds shell: pwsh run: pnpm run check:ci:windows-blocking + - name: Parse Windows runner supervision scripts + shell: pwsh + run: | + foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1')) { + $tokens = $null + $parseErrors = $null + $null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count) { throw ($parseErrors -join "`n") } + } - name: Run Windows observational gates on built artifacts id: observational continue-on-error: true diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 8f9c82af11..ba8f5b0896 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -6,11 +6,11 @@ on: workflow_dispatch: inputs: package_run_id: - description: 'Successful Package Desktop run ID' + description: '打包运行 ID:填写成功的 Package Desktop run ID;发布环境和 release ID 沿用原包' type: string required: true package_run_attempt: - description: 'Package attempt; blank selects latest, which must have succeeded' + description: '打包尝试次数:留空选择最新一次,且必须成功;失败时不会回退到旧包' type: string required: false default: '' @@ -31,6 +31,11 @@ jobs: - uses: actions/checkout@v7.0.1 with: persist-credentials: false + - name: Reject debugging packages before dependency installation + id: preflight + env: + GH_TOKEN: ${{ github.token }} + run: node apps/desktop/scripts/preflight-desktop-publish.mjs - uses: pnpm/action-setup@v5.0.0 - uses: actions/setup-node@v7.0.0 with: @@ -41,6 +46,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + DESKTOP_PACKAGE_ATTEMPT: ${{ steps.preflight.outputs.attempt }} run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts prepare: diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 1d6b8c6309..b0c3164729 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -4,40 +4,40 @@ on: workflow_dispatch: inputs: preset: - description: 'Machine preset (configured on the packaging runner)' + description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' type: choice options: [test, production] default: test required: true skip_notarization: - description: 'Skip notarization/stapling: internal debugging only, not for distribution or updates' + description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选' type: boolean default: false required: true test_release_id: - description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。' + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' type: string default: '' required: false source_ref: - description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' type: string default: master required: true update_channel: - description: 'Installer update channel; automatic updates stay on this channel' + description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' type: choice options: [nightly, latest] default: nightly required: true version_mode: - description: 'default: production follows product, test auto; manual uses build_version' + description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填' type: choice options: [default, product, auto, manual] default: default required: true build_version: - description: 'Manual version only: product version or its supported prerelease suffix' + description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀' type: string default: '' required: false @@ -74,9 +74,20 @@ jobs: - name: Validate selected source and adapter support run: >- node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; - if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET; + if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset'); + const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref); + if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*'); + if(tag) { const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; - if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + if(ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim(); + const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(); + if(tagged!==head) throw Error('Checkout does not match source tag'); + } else { + const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); + if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); + } if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 589917fa4e..bc76eca87b 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -4,41 +4,41 @@ on: workflow_dispatch: inputs: preset: - description: 'Machine preset (configured on the packaging runner)' + description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' type: choice options: [test, production] default: test required: true signing: - description: 'unsigned: test installer; signed: use the configured EV hardware token' + description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)' type: choice - options: [unsigned, signed] - default: unsigned + options: [signed, unsigned] + default: signed required: true test_release_id: - description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。' + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' type: string default: '' required: false source_ref: - description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' type: string default: master required: true update_channel: - description: 'Installer update channel; automatic updates stay on this channel' + description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' type: choice options: [nightly, latest] default: nightly required: true version_mode: - description: 'default: production follows product, test auto; manual uses build_version' + description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填' type: choice options: [default, product, auto, manual] default: default required: true build_version: - description: 'Manual version only: product version or its supported prerelease suffix' + description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀' type: string default: '' required: false @@ -86,9 +86,20 @@ jobs: - name: Validate selected source and adapter support run: >- node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; - if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET; + if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset'); + const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref); + if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*'); + if(tag) { const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; - if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + if(ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim(); + const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(); + if(tagged!==head) throw Error('Checkout does not match source tag'); + } else { + const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); + if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); + } if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: diff --git a/AGENTS.md b/AGENTS.md index 7bae0a79c1..5e2108e151 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ Package groups: [packages/README.md](packages/README.md). [Experimental status]( ## Commands ```sh -pnpm install # pnpm workspaces, node ^22.19 || >=24 +pnpm install # pnpm workspaces, node >=24.9 pnpm run clean # remove build outputs and safe residue from deleted packages pnpm run test # unit tests pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src @@ -129,7 +129,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and ## Conventions - 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. +- 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) ([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 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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6a2fbda5eb..43b8d64815 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this ## Runtime npm dependencies -External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. +External packages installed for runtime use or embedded in distributed Host or browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. Host embedding declarations identify the distributing package and its embedded dependencies. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. | Package | License | | --- | --- | @@ -35,9 +35,11 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript) | SEE LICENSE IN README.md | | [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT | | [`@babel/code-frame`](https://github.com/babel/babel) | MIT | +| [`@botiverse/oar`](https://github.com/botiverse/oar) | Apache-2.0 | | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | | [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) | MPL-2.0 | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | +| [`@earendil-works/pi-coding-agent`](https://github.com/earendil-works/pi) | MIT | | [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp) | MIT | | [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT | @@ -83,6 +85,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | | [`compression`](https://github.com/expressjs/compression) | MIT | +| [`cross-spawn`](https://github.com/moxystudio/node-cross-spawn) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) | | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | @@ -92,6 +95,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`got`](https://github.com/sindresorhus/got) | MIT | +| [`hosted-git-info`](https://github.com/npm/hosted-git-info) | ISC | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | @@ -110,6 +114,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | | [`mime-types`](https://github.com/jshttp/mime-types) | MIT | +| [`minisearch`](https://github.com/lucaong/minisearch) | MIT | | [`negotiator`](https://github.com/jshttp/negotiator) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | @@ -128,12 +133,15 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 | +| [`string_decoder`](https://github.com/nodejs/string_decoder) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | +| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 | | [`tsx`](https://github.com/privatenumber/tsx) | MIT | | [`turndown`](https://github.com/mixmark-io/turndown) | MIT | | [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | | [`undici`](https://github.com/nodejs/undici) | MIT | | [`use-sync-external-store`](https://github.com/facebook/react) | MIT | +| [`which`](https://github.com/isaacs/node-which) | ISC | | [`ws`](https://github.com/websockets/ws) | MIT | | [`xlsx`](https://git.sheetjs.com/SheetJS/sheetjs) | Apache-2.0 | | [`yaml`](https://github.com/eemeli/yaml) | ISC | @@ -142,7 +150,8 @@ External packages installed for runtime use or distributed inside the prebuilt b pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: -- `@earendil-works/pi-ai@1.1.0` — [`patches/@earendil-works__pi-ai@1.1.0.patch`](patches/@earendil-works__pi-ai@1.1.0.patch) +- `@botiverse/oar@0.67.0` — [`patches/@botiverse__oar@0.67.0.patch`](patches/@botiverse__oar@0.67.0.patch) +- `@earendil-works/pi-ai@1.3.0` — [`patches/@earendil-works__pi-ai@1.3.0.patch`](patches/@earendil-works__pi-ai@1.3.0.patch) - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) - `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch) - `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) @@ -183,7 +192,7 @@ The [kit repository](https://github.com/deepseek-ai/dsh-libreoffice-kit) supplie ## Development-only npm dependencies -External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. +External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or distribution inside a Host or browser bundle. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. | Package | License | | --- | --- | @@ -202,6 +211,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/hosted-git-info`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -252,7 +262,6 @@ External packages **directly declared** for development, tests, types, or toolin | [`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 | | [`tsdown`](https://github.com/rolldown/tsdown) | MIT | | [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 | | [`vite`](https://github.com/vitejs/vite) | MIT | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 32e37d9c16..1ec1be3c64 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -12,8 +12,8 @@ en: cc03e6b75206661b zh: 99e41f4143198352 /deepseek-ai-dsh/profiles: - en: 79cad979b0f7895c - zh: b0f922a4b43accf8 + en: 523477a093beb0c2 + zh: f88ea21e5fbea969 /deepseek-ai-dsh/optional-overlays: en: 3a40ddc44e55f79c zh: 8d3949132e1ef93c diff --git a/apps/cli/README.md b/apps/cli/README.md index ed3685fd1b..b0059efecd 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -36,7 +36,7 @@ dsh --help # the launcher's own help A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer). `dsh-hmr`, when enabled in YAML, watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload. Without HMR, changes apply on restart. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections. CLI package commands inherit authentication variables and terminal descriptors, including interactive build approval; service calls retain their scrubbed environment and captured diagnostics. -Installation and profile startup enforce declared DSH peer ranges against the same runtime version shown by `dsh --version`. Incompatible plugins require an explicitly acknowledged exact-version exemption. The [plugin manager's compatibility reference](../../packages/boot/plugin-manager/README.md#version-compatibility-and-exemptions) documents `version-exemptions`, `allow-version`, `revoke-version`, persistence, and risks. +Installation and profile startup enforce the declared `engines.dsh` range, or DSH peer ranges when it is absent, against the same runtime version shown by `dsh --version`. Incompatible plugins require an explicitly acknowledged exact-version exemption. The [plugin manager's compatibility reference](../../packages/boot/plugin-manager/README.md#version-compatibility-and-exemptions) documents `version-exemptions`, `allow-version`, `revoke-version`, persistence, and risks. The tree composes over an empty root: - each bundle's patch in `dsh.profile.bundles` order diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 84d3977675..810126e839 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -36,7 +36,7 @@ dsh --help # the launcher's own help profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。在 YAML 中启用的 `dsh-hmr` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层。未启用 HMR 时,更改在重启后生效。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。CLI 包操作继承认证环境和终端描述符,支持交互式构建批准;service 调用保留清理后的环境并捕获诊断。 -安装和 profile 启动会按声明的 DSH peer 范围,检查与 `dsh --version` 显示值相同的运行时版本。不兼容插件需要用户明确确认精确版本豁免。[插件管理器的兼容性参考](../../packages/boot/plugin-manager/README.zh.md#version-compatibility-and-exemptions)说明 `version-exemptions`、`allow-version`、`revoke-version`、持久化规则与风险。 +安装和 profile 启动会按声明的 `engines.dsh` 范围(未声明时按 DSH peer 范围),检查与 `dsh --version` 显示值相同的运行时版本。不兼容插件需要用户明确确认精确版本豁免。[插件管理器的兼容性参考](../../packages/boot/plugin-manager/README.zh.md#version-compatibility-and-exemptions)说明 `version-exemptions`、`allow-version`、`revoke-version`、持久化规则与风险。 配置树以空根为起点,依次叠加以下配置层: - `dsh.profile.bundles` 中各组合包的 patch diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 8b2cfb83ab..cda1e0bb96 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -146,8 +146,6 @@ flowchart LR cfg --> plugin_dsh_base_subagent_fork_in_process plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_dsh_base_tool_subagent_control - plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] - cfg --> plugin_dsh_base_tool_subagent_list_agents plugin_dsh_base_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] cfg --> plugin_dsh_base_tool_subagent plugin_dsh_base_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] @@ -180,6 +178,8 @@ flowchart LR cfg --> plugin_dsh_base_web plugin_dsh_base_web_search_deepseek["web-search-deepseek
@deepseek-ai/dsh-web-search-deepseek"] cfg --> plugin_dsh_base_web_search_deepseek + plugin_dsh_base_web_deepseek_api["web-deepseek-api
@deepseek-ai/dsh-web-deepseek-api"] + cfg --> plugin_dsh_base_web_deepseek_api plugin_dsh_base_web_fetch_http["web-fetch-http
@deepseek-ai/dsh-web-fetch-http"] cfg --> plugin_dsh_base_web_fetch_http plugin_dsh_base_tool_web["tool-web
@deepseek-ai/dsh-tool-web"] @@ -271,7 +271,6 @@ flowchart LR | `subagent-spawn-in-process` | `@deepseek-ai/dsh-subagent-spawn-in-process` | | `subagent-fork-in-process` | `@deepseek-ai/dsh-subagent-fork-in-process` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | -| `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `ptc-runtime` | `@deepseek-ai/dsh-ptc-runtime-node` | @@ -288,6 +287,7 @@ flowchart LR | `repeat-tool-reminder` | `@deepseek-ai/dsh-repeat-tool-reminder` | | `web` | `@deepseek-ai/dsh-web` | | `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` | +| `web-deepseek-api` | `@deepseek-ai/dsh-web-deepseek-api` | | `web-fetch-http` | `@deepseek-ai/dsh-web-fetch-http` | | `tool-web` | `@deepseek-ai/dsh-tool-web` | | `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` | diff --git a/apps/cli/package.json b/apps/cli/package.json index b841d617f0..6f4f927b48 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.2", + "version": "0.2.1-rc.2", "publishConfig": { "access": "public" }, @@ -104,7 +104,8 @@ "@deepseek-ai/dsh-experimental-session-titles-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-terminal-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-session-search": "workspace:*", - "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*" + "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", + "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-skill-badge": "workspace:*", @@ -168,5 +169,8 @@ }, "./lib/*": "./lib/*", "./package.json": "./package.json" + }, + "engines": { + "node": ">=24.9.0" } } diff --git a/apps/cli/tests/lazy-search-startup.compat.spec.ts b/apps/cli/tests/lazy-search-startup.compat.spec.ts index d2172ebe57..dfb92dc340 100644 --- a/apps/cli/tests/lazy-search-startup.compat.spec.ts +++ b/apps/cli/tests/lazy-search-startup.compat.spec.ts @@ -1,5 +1,5 @@ /** - * Node 22 startup-output smoke for the shipped Web CLI composition. + * Node 24 startup-output smoke for the shipped Web CLI composition. * * Only the dedicated Node compatibility gate opts this test in after building * both artifacts; ordinary Vitest inventory deterministically skips it. diff --git a/apps/cli/tests/plugin.spec.ts b/apps/cli/tests/plugin.spec.ts index bba6767429..bb757eb5e0 100644 --- a/apps/cli/tests/plugin.spec.ts +++ b/apps/cli/tests/plugin.spec.ts @@ -87,7 +87,7 @@ it('names the exact grant command for each package a compatibility check refused const { stderr } = fixture() vi.mocked(runPluginCommand).mockResolvedValue({ exitCode: 1, output: '', truncated: false, logPath: '/profile/log', - incompatible: [{ name: '@example/plugin', version: '1.2.3', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^9.0.0' } }], + incompatible: [{ name: '@example/plugin', version: '1.2.3', runtimeVersion: '0.1.0', requirements: { '@deepseek-ai/dsh': '^9.0.0' } }], }) expect(await runPlugin('test', ['add', '@example/plugin'])).toBe(1) expect(stderr.mock.calls.map(call => call[0])).toEqual([ diff --git a/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts new file mode 100644 index 0000000000..9468f874cc --- /dev/null +++ b/apps/cli/tests/profiles/acp/tests/stdout.expected.e2e.ts @@ -0,0 +1,102 @@ +/** Account catalog diagnostics preserve the shipped ACP profile's JSON-RPC stream. */ +import { once } from 'node:events' +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot' +import { expect, it } from 'vitest' +import { cleanupAcpExampleTest } from './cleanup.ts' + +it('keeps account model-listing diagnostics on stderr during API-key session creation', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-')) + const token = 'acp-catalog-fixture-token' + const requests: Array<{ method: string | undefined; path: string | undefined; token: string | string[] | undefined }> = [] + const server = createServer((request, response) => { + requests.push({ method: request.method, path: request.url, token: request.headers['x-dsh-auth-token'] }) + request.resume() + if (request.method !== 'GET' || request.url !== '/models') { + response.writeHead(500).end('unexpected request') + return + } + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ + data: [ + { + id: 'deepseek-flash', name: 'Account fixture model', input_modalities: ['text'], output_modalities: ['text'], + context_window: 1_048_576, max_output_tokens: 393_216, api_capabilities: { anthropic_messages: {} }, + }, + { id: 'deepseek-v4-pro' }, + ], + })) + }) + let launched: LaunchedAcpTestAgent | undefined + try { + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('account catalog server has no port') + const origin = `http://127.0.0.1:${address.port}` + await mkdir(join(cwd, '.dsh')) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), JSON.stringify({ + version: 1, + records: { + 'deepseek-account-platform/default': { kind: 'grant', payload: { version: 1, token, issuer: origin } }, + }, + }) + '\n', { mode: 0o600 }) + const configPath = join(cwd, 'catalog.patch.yml') + await writeFile(configPath, JSON.stringify([ + { id: 'deepseek-account', config: { platformOrigin: origin, inferenceOrigin: origin, allowLoopbackHttp: true } }, + { id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic` } }, + { id: 'llm-deepseek', config: { baseURL: `${origin}/anthropic`, models: [{ id: 'fixture-api-key-model' }] } }, + // Catalog discovery requires the account service to have loaded the stored grant. + { + id: 'acp', inject: ['acpAppStartup', 'deepseekAccount'], + config: { provider: 'deepseek-official', model: 'fixture-api-key-model' }, + }, + { id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } }, + ]) + '\n') + const agent: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)), + configPath, + profile: 'acp', + tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)), + } + launched = launchAcpTestAgent({ + agent, + cwd, + env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: `${origin}/anthropic`, DSH_TELEMETRY_DISABLED: '1' }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await launched.client.newSession({ cwd, mcpServers: [] }) + expect(created.configOptions).toContainEqual(expect.objectContaining({ + id: 'model', currentValue: JSON.stringify(['deepseek-official', 'fixture-api-key-model']), + })) + await launched.client.closeSession({ sessionId: created.sessionId }) + // Closing joins both pipes; receiving a stdout response alone does not drain stderr. + await launched.close() + + expect(launched.child.signalCode).toBeNull() + expect(launched.child.exitCode).toBe(0) + expect(requests).toEqual([{ method: 'GET', path: '/models', token }]) + const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record) + expect(frames.length).toBeGreaterThanOrEqual(3) + for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' }) + expect(launched.stderr()).toContain(`[llm-deepseek-account] model listing from ${origin}/models loaded 2 model(s), skipped 0 row(s)`) + expect(launched.stderr()).toContain('[llm-deepseek-account] model "deepseek-v4-pro" declares no api_capabilities.anthropic_messages') + expect(launched.stderr()).not.toContain(token) + } finally { + const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd)]) + server.closeAllConnections() + cleanup.push(...await Promise.allSettled([ + server.listening ? new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) : undefined, + ])) + const failures: unknown[] = [] + for (const result of cleanup) if (result.status === 'rejected') failures.push(result.reason) + if (failures.length > 0) throw new AggregateError(failures, 'ACP catalog test cleanup failed') + } +}) diff --git a/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml b/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml index 8293d9919f..0a737948e8 100644 --- a/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/credentials-snapshot.patch.yml @@ -30,3 +30,9 @@ name: '@deepseek-ai/dsh-llm-deepseek-api-key' config: baseURL: 'http://127.0.0.1:9' + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/goal-snapshot.patch.yml b/apps/cli/tests/profiles/headless/goal-snapshot.patch.yml index 3778da03de..5abe21e491 100644 --- a/apps/cli/tests/profiles/headless/goal-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/goal-snapshot.patch.yml @@ -31,3 +31,9 @@ - id: session-log-deepseek config: enabled: false + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/real-model.patch.yml b/apps/cli/tests/profiles/headless/real-model.patch.yml index b0c161b3c6..1a264aac93 100644 --- a/apps/cli/tests/profiles/headless/real-model.patch.yml +++ b/apps/cli/tests/profiles/headless/real-model.patch.yml @@ -30,3 +30,9 @@ config: root: './.sessions' compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/retry-snapshot.patch.yml b/apps/cli/tests/profiles/headless/retry-snapshot.patch.yml index ff13fa3303..9870ef3678 100644 --- a/apps/cli/tests/profiles/headless/retry-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/retry-snapshot.patch.yml @@ -24,3 +24,9 @@ - insert: - id: retry-snapshot-backend name: './tests/fixtures/retry-snapshot-backend.mjs' + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/semantic-checkpoint-snapshot.patch.yml b/apps/cli/tests/profiles/headless/semantic-checkpoint-snapshot.patch.yml index 0c818b1641..5007125e1c 100644 --- a/apps/cli/tests/profiles/headless/semantic-checkpoint-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/semantic-checkpoint-snapshot.patch.yml @@ -27,3 +27,9 @@ - id: session-log-deepseek config: enabled: false + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/subagent-diagnostic-snapshot.patch.yml b/apps/cli/tests/profiles/headless/subagent-diagnostic-snapshot.patch.yml index 49b42c91e2..0cdb4c5aff 100644 --- a/apps/cli/tests/profiles/headless/subagent-diagnostic-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/subagent-diagnostic-snapshot.patch.yml @@ -30,3 +30,9 @@ - id: session-log-deepseek config: enabled: false + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml b/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml index d347a9cfe4..7511e92167 100644 --- a/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml @@ -10,6 +10,7 @@ disabled: true - id: sandbox-policy + inject: [] config: mode: workspace-write workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/subagent-settlement-snapshot.patch.yml b/apps/cli/tests/profiles/headless/subagent-settlement-snapshot.patch.yml index 2b4b636ecb..2c56a67f6f 100644 --- a/apps/cli/tests/profiles/headless/subagent-settlement-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/subagent-settlement-snapshot.patch.yml @@ -35,3 +35,9 @@ - id: session-log-deepseek config: enabled: false + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml index 290714194e..dec80a9fe9 100644 --- a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml @@ -24,9 +24,6 @@ - id: tool-subagent-control disabled: true -- id: tool-subagent-list-agents - disabled: true - - id: tool-subagent-report disabled: true @@ -53,3 +50,9 @@ - id: team-fixture-llm name: './tests/fixtures/team-llm.mjs' + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/auto-review.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/auto-review.patch.yml new file mode 100644 index 0000000000..e9fec61f3c --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/fixtures/auto-review.patch.yml @@ -0,0 +1,8 @@ +# Explicit experimental integration with only the reviewer response scripted. +- insert: + - id: auto-review + name: '@deepseek-ai/dsh-experimental-auto-review' + - id: headless-auto-review-fixture + name: '../../../../../../../packages/experimental/auto-review/tests/fixtures/headless-reviewer.ts' + config: + mode: !!js process.env.DSH_AUTO_REVIEW_TEST_MODE diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/auto-shutdown-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/auto-shutdown-llm.mjs new file mode 100644 index 0000000000..7c9584c360 --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/fixtures/auto-shutdown-llm.mjs @@ -0,0 +1,25 @@ +import { LlmAdapter } from '@deepseek-ai/dsh-llm' + +/** A completed turn with no tool or auxiliary model work. */ +class AutoShutdownAdapter extends LlmAdapter { + async * stream() { + const text = 'AUTO_SHUTDOWN_OK' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Cordis fixture name. */ +export const name = 'auto-shutdown-llm' +/** The production LLM registry owns the fixture adapter. */ +export const inject = ['llm'] + +/** + * Register the deterministic no-tool model. + * @param {import('@deepseek-ai/cordis').Context} ctx - fixture context. + */ +export function apply(ctx) { + ctx.llm.registerAdapter(['auto-shutdown'], new AutoShutdownAdapter()) +} diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/auto-shutdown.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/auto-shutdown.patch.yml new file mode 100644 index 0000000000..3171d8aee3 --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/fixtures/auto-shutdown.patch.yml @@ -0,0 +1,12 @@ +# No-tool model response leaves permission persistence to the shipped profile. +- id: agent-default-model + config: + provider: auto-shutdown + model: auto-shutdown + +- id: session-title + disabled: true + +- insert: + - id: auto-shutdown-llm + name: './auto-shutdown-llm.mjs' diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml index 76a138452d..d4c74ac347 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml @@ -35,3 +35,9 @@ - insert: - id: cli-mock-llm name: '../../../../../../../packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts' + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/deepseek-defaults.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/deepseek-defaults.patch.yml index 6809f21a93..28150a1a47 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/deepseek-defaults.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/deepseek-defaults.patch.yml @@ -27,3 +27,9 @@ config: root: './.sessions' compression: none + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() 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 index 6d2a8dde06..4029cf5fd2 100644 --- 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 @@ -34,3 +34,9 @@ - insert: - id: dynamic-tool-prompt-updates name: '../../../../../../../packages/test-support/session-snapshot/tests/fixtures/dynamic-tool-prompt-updates.ts' + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-defaults.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-defaults.patch.yml index a14bbb4d37..a3e983cee7 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-defaults.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-defaults.patch.yml @@ -35,3 +35,9 @@ config: root: './.sessions' compression: none + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() 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 2e45acbb6c..b71ffc778c 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { tmpdir } from 'node:os' @@ -44,6 +44,9 @@ const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-de 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 autoReviewOverlayPath = fileURLToPath(new URL('./fixtures/auto-review.patch.yml', import.meta.url)) +const autoShutdownOverlayPath = fileURLToPath(new URL('./fixtures/auto-shutdown.patch.yml', import.meta.url)) +const autoIntegrationPath = fileURLToPath(new URL('../../../../../../packages/experimental/auto-review/cordis.patch.yml', import.meta.url)) const headlessSessionExpected = join(goldensDir, 'headless-profile', 'session.expected.jsonl') const headlessReasoningExpected = join(goldensDir, 'headless-profile', 'reasoning.stderr.expected.txt') const headlessFailureExpected = join(goldensDir, 'headless-profile', 'stderr.expected.txt') @@ -299,6 +302,86 @@ async function persistedLogs(cwd: string, root: string = join(cwd, '.sessions')) describe('headless stream-json snapshots', () => { + it('preserves Auto through shutdown and requires its integration when reopening', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'headless-auto-shutdown-')) + const options = { + cwd, + binScript: dshBinScript, + configPath: autoShutdownOverlayPath, + tsconfigPath, + env: { DSH_TELEMETRY_DISABLED: '1' }, + } + const args = ['headless', '--patch', autoShutdownOverlayPath] + let sessionId = '' + try { + for (const wake of [1, 2]) { + const result = await runLoaderSmoke({ + ...options, + label: `Auto shutdown wake ${wake}`, + binArgs: [ + ...args, '--patch', autoIntegrationPath, '--json', + ...wake === 1 ? ['--permission-mode', 'auto'] : ['--session-id', sessionId], + 'Record this wake without calling tools.', + ], + inspect: async () => { + const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) + expect(logs).toHaveLength(1) + const log = logs[0]! + expect(typeof log.header.id).toBe('string') + sessionId = log.header.id as string + const events = parseJsonl(log.content) + expect(events.filter(event => event.type === 'permission/preset').at(-1)?.data) + .toEqual({ preset: 'auto' }) + expect(events.filter(event => event.type === 'approval/policy').at(-1)?.data) + .toEqual({ policy: 'ask' }) + expect(events.filter(event => event.type === 'turn/end')).toHaveLength(wake) + }, + }) + expect(parseJsonl(result.stdout).at(-1)).toMatchObject({ + type: 'final', sessionId, reason: { kind: 'completed' }, text: 'AUTO_SHUTDOWN_OK', + }) + } + const beforeRejectedRestore = (await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')))[0]!.content + const unavailable = await runLoaderSmoke({ + ...options, + label: 'Auto restore without its integration', + expectedExitCode: 1, + binArgs: [...args, '--json', '--session-id', sessionId, '--permission-mode', 'read-only', 'Continue.'], + }) + expect(unavailable.stderr).toContain('cannot restore preset "auto" without its active integration') + expect(parseJsonl(unavailable.stdout).some(event => event.type === 'final')).toBe(false) + const afterRejectedRestore = (await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')))[0]!.content + expect(afterRejectedRestore.startsWith(beforeRejectedRestore)).toBe(true) + // Restore records its seed boundary before live publication validates the saved permission. + const appended = parseJsonl(afterRejectedRestore.slice(beforeRejectedRestore.length)) + expect(appended).toHaveLength(1) + const { time, ...marker } = appended[0]! + expect(typeof time).toBe('number') + expect(marker).toEqual({ + type: 'session/end-seed', + seq: parseJsonl(beforeRejectedRestore).length - 1, + data: {}, + }) + await runLoaderSmoke({ + ...options, + label: 'Change restored Auto to Read only with its integration', + binArgs: [ + ...args, '--patch', autoIntegrationPath, '--json', + '--session-id', sessionId, '--permission-mode', 'read-only', 'Continue.', + ], + inspect: async () => { + const log = (await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')))[0]! + const restored = parseJsonl(log.content) + expect(restored.filter(event => event.type === 'permission/preset').at(-1)?.data) + .toEqual({ preset: 'read-only' }) + expect(restored.filter(event => event.type === 'turn/end')).toHaveLength(3) + }, + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS * 4) + it.each(['src', 'lib'] as const)('runs one task through the product headless profile command (%s)', async (mode) => { const task = 'Prove the product headless profile path with one real tool round trip.' const result = await runLoaderSmoke({ @@ -345,6 +428,8 @@ describe('headless stream-json snapshots', () => { configPath: headlessOverlayPath, binArgs: [ '--profile', 'headless', '--patch', headlessOverlayPath, + '--permission-mode', 'workspace-write', + ...process.platform === 'win32' ? [] : ['--add-dir', await realpath(tmpdir())], '--json', task, ], tsconfigPath, @@ -353,16 +438,24 @@ describe('headless stream-json snapshots', () => { DSH_TELEMETRY_DISABLED: '1', NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) + const content = logs[0]?.content ?? '' + const modes = parseJsonl(content).filter(event => event.type === 'sandbox/mode') + expect(modes.at(-1)?.data).toEqual({ mode: 'workspace-write' }) + if (process.platform !== 'win32') expect(content).toContain('Additional writable directories:') + }, }) const events = result.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject) expect(events[0]).toMatchObject({ type: 'session' }) expect(events[0]?.sessionId).toMatch(/^session-/) expect(typeof events[0]?.cwd).toBe('string') - expect(events.at(-1)).toMatchObject({ type: 'final', text: 'CLI tool round trip complete: CLI_TOOL_ROUND_TRIP' }) + expect(events.at(-1)).toMatchObject({ type: 'final', sessionId: events[0]?.sessionId, reason: { kind: 'completed' }, text: 'CLI tool round trip complete: CLI_TOOL_ROUND_TRIP' }) expect(events.map(event => event.type)).toContain('thinking') expect(events.map(event => event.type)).toContain('tool_call') expect(events.map(event => event.type)).toContain('tool_result') + expect(events.filter(event => event.type === 'tool_result').every(event => event.status === 'completed')).toBe(true) expect(events.map(event => event.type)).not.toContain('error') expect(result.stderr).toBe('') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -424,7 +517,7 @@ describe('headless stream-json snapshots', () => { configPath: headlessOverlayPath, binArgs: [ '--profile', 'headless', '--patch', headlessOverlayPath, - '--json', '--session-id', sessionId, secondTask, + '--json', '--permission-mode', 'read-only', '--session-id', sessionId, secondTask, ], tsconfigPath, env, @@ -434,12 +527,16 @@ describe('headless stream-json snapshots', () => { const content = logs[0]?.content ?? '' expect(content).toContain(firstTask) expect(content).toContain(secondTask) + const modes = parseJsonl(content).filter(event => event.type === 'sandbox/mode') + expect(modes.at(-1)?.data).toEqual({ mode: 'read-only' }) }, }) const secondEvents = second.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject) expect(secondEvents[0]).toMatchObject({ type: 'session', sessionId }) expect(secondEvents.at(-1)).toMatchObject({ type: 'final', + sessionId, + reason: { kind: 'completed' }, text: 'CLI tool round trip complete: CLI_TOOL_ROUND_TRIP', }) } finally { @@ -447,6 +544,97 @@ describe('headless stream-json snapshots', () => { } }, LOADER_SMOKE_TEST_TIMEOUT_MS * 2) + it.each(['allow', 'deny', 'malformed', 'failed'])('runs Auto review with noninteractive %s results', async (mode) => { + const result = await runLoaderSmoke({ + label: `headless Auto review ${mode}`, + tempDirPrefix: 'headless-auto-', + binScript: dshBinScript, + configPath: autoReviewOverlayPath, + binArgs: [ + '--profile', 'headless', '--patch', headlessOverlayPath, '--patch', autoReviewOverlayPath, + '--permission-mode', 'auto', '--approval-policy', 'never', '--json', 'Run the CLI probe.', + ], + tsconfigPath, + env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) + expect(logs).toHaveLength(1) + const events = parseJsonl(logs[0]!.content) + expect(events.find(event => event.type === 'user/message')?.data).toMatchObject({ + source: { kind: 'user' }, + }) + expect(events.some(event => event.type === 'approval/asked')).toBe(false) + expect(events.filter(event => event.type === 'approval/policy').at(-1)?.data).toEqual({ policy: 'never' }) + }, + }) + const events = parseJsonl(result.stdout) + const tool = events.find(event => event.type === 'tool_result') + if (mode === 'allow') { + expect(tool?.status).toBe('completed') + expect(tool?.result).toContain('CLI_TOOL_ROUND_TRIP') + expect(tool).not.toHaveProperty('error') + } else { + expect(tool).toMatchObject({ + status: 'error', + error: { code: mode === 'deny' ? 'AUTO_REVIEW_DENIED' : 'AUTO_REVIEW_FAILED' }, + }) + if (mode === 'deny') expect(tool?.error).toMatchObject({ reason: 'The caller did not authorize this target.' }) + expect(tool?.result).toContain('body was not executed') + expect(tool?.result).not.toContain('CLI_TOOL_ROUND_TRIP') + } + expect(events.at(-1)).toMatchObject({ type: 'final', reason: { kind: 'completed' } }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('rejects Auto selection without the experimental integration', async () => { + const result = await runLoaderSmoke({ + label: 'headless absent Auto integration', tempDirPrefix: 'headless-auto-absent-', + binScript: dshBinScript, configPath: headlessOverlayPath, + binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, '--permission-mode', 'auto', '--json', 'Do it.'], + tsconfigPath, expectedExitCode: 1, env: { DSH_TELEMETRY_DISABLED: '1' }, + }) + const events = parseJsonl(result.stdout) + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe('error') + expect(events[0]?.message).toContain('auto') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('resumes an Auto denial with caller attribution and saved policy', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'headless-auto-resume-')) + const args = ['--profile', 'headless', '--patch', headlessOverlayPath, '--patch', autoReviewOverlayPath, '--json'] + try { + const first = await runLoaderSmoke({ + label: 'headless Auto denied wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, + binArgs: [...args, '--permission-mode', 'auto', '--approval-policy', 'never', 'Run the CLI probe.'], tsconfigPath, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + }) + const sessionId = parseJsonl(first.stdout)[0]?.sessionId + if (typeof sessionId !== 'string') throw new Error('missing Auto Session identity') + expect(parseJsonl(first.stdout).find(event => event.type === 'tool_result')).toMatchObject({ + error: { code: 'AUTO_REVIEW_DENIED' }, + }) + const second = await runLoaderSmoke({ + label: 'headless Auto authorized wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, + binArgs: [...args, '--session-id', sessionId, 'I authorize the exact CLI probe. Retry it.'], tsconfigPath, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + inspect: async (directory) => { + const logs = await persistedLogs(directory, join(directory, '.dsh', 'sessions')) + expect(logs).toHaveLength(1) + const events = parseJsonl(logs[0]!.content) + const sources = events.filter(event => event.type === 'user/message') + .map(event => (event.data as { source: { kind: string } }).source) + .filter(source => source.kind === 'user') + expect(sources).toEqual([{ kind: 'user' }, { kind: 'user' }]) + expect(events.filter(event => event.type === 'permission/preset').at(-1)?.data).toEqual({ preset: 'auto' }) + expect(events.some(event => event.type === 'approval/asked')).toBe(false) + }, + }) + expect(parseJsonl(second.stdout).at(-1)).toMatchObject({ + type: 'final', sessionId, reason: { kind: 'completed' }, + text: 'CLI tool round trip complete: CLI_TOOL_ROUND_TRIP', + }) + } finally { await rm(cwd, { recursive: true, force: true }) } + }, LOADER_SMOKE_TEST_TIMEOUT_MS * 2) + it('prints a terminal model failure through the product headless profile command', async () => { const result = await runLoaderSmoke({ label: 'product headless profile model failure snapshot', diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts index 9978557402..9c4b58df8d 100644 --- a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts +++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts @@ -225,6 +225,7 @@ export function testProfileResolution(mode: ExampleMode): void { ].join('\n'), 'cordis.patch.yml': JSON.stringify([ { id: 'headless-startup', disabled: true }, + { id: 'sandbox-policy', inject: [], config: { mode: process.env.DSH_PERMISSION_MODE ?? 'workspace-write' } }, { id: 'headless-runner', disabled: true }, { id: 'llm-deepseek', disabled: true }, { insert: [ diff --git a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts index 9eae3e8f4a..4988ef80fd 100644 --- a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts @@ -32,6 +32,7 @@ describe.skipIf(!existsSync(join(repoRoot, 'apps/cli/lib/bin.js')))('dsh SOURCE const patch = join(root, 'source-tool.patch.yml') await writeFile(patch, JSON.stringify([ { id: 'headless-startup', disabled: true }, + { id: 'sandbox-policy', inject: [], config: { mode: process.env.DSH_PERMISSION_MODE ?? 'workspace-write', workspaceRoot: cwd } }, { id: 'headless-runner', disabled: true }, { id: 'llm-deepseek', disabled: true }, { id: 'agent-default-model', config: { provider: 'cli-mock', model: 'cli-mock' } }, diff --git a/apps/cli/tests/profiles/headless/workspace-context-resume-snapshot.patch.yml b/apps/cli/tests/profiles/headless/workspace-context-resume-snapshot.patch.yml index 42a63474af..b7a2dbd8ca 100644 --- a/apps/cli/tests/profiles/headless/workspace-context-resume-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/workspace-context-resume-snapshot.patch.yml @@ -31,3 +31,9 @@ - id: session-log-deepseek config: enabled: false + +- id: sandbox-policy + inject: [] + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' + workspaceRoot: !!js process.cwd() diff --git a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts index 3bdc2e1cc5..b1b2c88128 100644 --- a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts +++ b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import { lastAssistantStreamChunk, type RetryPolicyConfig, type TokenUsage } from '@deepseek-ai/dsh-llm' import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { describe, expect, it, onTestFinished } from 'vitest' @@ -22,6 +22,8 @@ interface ObservedRequest { step: number body: { system?: string + max_tokens: number + thinking: { type: string } messages: { role: string; content: { type: string; text?: string; tool?: { name: string } }[] }[] tools?: { name: string; defer_loading?: true }[] } @@ -70,7 +72,7 @@ function registryChanges(previous: ObservedRequest, request: ObservedRequest): { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with real DeepSeek', () => { - it.each([false, true])('keeps the preceding conversation cached after a tool addition (prompt update: %s)', { retry: 0 }, async (updatePrompt) => { + it.each([false, true])('keeps the preceding conversation cached after a tool addition (prompt update: %s)', async (updatePrompt) => { const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-tool-cache-')) onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) const sampleLabel = `CACHE_SAMPLE_${randomUUID()}` @@ -80,7 +82,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea await writeFile(patch, JSON.stringify([ { id: 'llm-deepseek', config: { apiKeyEnv: 'DEEPSEEK_API_KEY', thinking: 'disabled', maxTokens: 1024, - retryPolicy: { maxRetries: 0 }, + retryPolicy: { mode: 'normal', maxRetries: 0 } satisfies RetryPolicyConfig, models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }], } }, { id: 'tools', config: { mode: 'native' } }, @@ -101,13 +103,19 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea const run = async (prompt: string) => { const result = await session.run(prompt) events.push(...result.events) + const failures = result.events.filter(event => event.type === 'assistant/attempt').map(event => ({ + turn: event.data.turn, + step: event.data.step, + reason: lastAssistantStreamChunk(event.data.stream, 'finish')?.reason, + })) + expect(failures, `Provider request failures: ${JSON.stringify(failures)}`).toEqual([]) return result.finalResponse } - await run('Do not call tools. Reply READY.') + expect(await run('Do not call tools. Reply READY.')).toContain('READY') // Unique user history makes hits on shared profile instructions insufficient. const records = Array.from({ length: 96 }, (_, index) => `record-${index}: ${randomUUID()}`).join('\n') - await run(`Keep these reference records. Do not call tools. Reply READY.\n${records}`) - await run('Keep the reference records. Do not call tools. Reply READY.') + expect(await run(`Keep these reference records. Do not call tools. Reply READY.\n${records}`)).toContain('READY') + expect(await run('Keep the reference records. Do not call tools. Reply READY.')).toContain('READY') expect(await run(`Use cache_tool_control with action add. After it returns, call ${sampleTool} exactly once and report the generated sample label. This is synthetic example data for this run. Call these separately in order.`)).toContain(sampleLabel) expectCalledBefore(await recordedCalls(callsPath), 'add', 'sample') expect(await run('Use cache_tool_control with action remove. Whether it removes the sample tool or reports that it is not registered, reply REMOVED and do not call the sample tool.')).toContain('REMOVED') @@ -115,7 +123,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea expectCalledBefore(calls, 'sample', 'remove') const requests = (await readFile(evidencePath, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as ObservedRequest) - expect(events.filter(event => event.type === 'assistant/attempt')).toHaveLength(0) expect(requests).toHaveLength(events.filter(event => event.type === 'assistant/message').length) const [initial, history, warm] = requests const addedIndex = requests.findIndex(request => request.stage === 'added') @@ -123,6 +130,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea const added = requests[addedIndex] const removed = requests.find(request => request.stage === 'removed') if (!initial || !history || !warm || !before || !added || !removed) throw new Error('Missing cache transition requests') + expect(initial.body).toMatchObject({ max_tokens: 1024, thinking: { type: 'disabled' } }) const historyInput = totalInput(usageFor(history, events)) const precedingInput = totalInput(usageFor(before, events)) const cached = usageFor(added, events).cacheReadTokens ?? 0 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 4d1f9210a2..b8e742ac0b 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -298,7 +298,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'read', 'read_image', + 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'present', 'read', 'read_image', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update', 'send_message', 'skill', 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search', @@ -328,15 +328,21 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) try { - expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') - expect(toolParameterNames(ctx, disabled.agent, 'subagent')).not.toEqual(expect.arrayContaining([ + expect(toolNames(ctx, disabled.agent)).toContain('list_subagent_models') + expect(toolParameterNames(ctx, disabled.agent, 'subagent')).toEqual(expect.arrayContaining([ 'model', 'provider', 'reasoning_effort', ])) + expect(ctx.sessionProjections.stateOf(disabled.agent.session, 'subagentModelSelectionPolicy')).toBeNull() + expect(ctx.sessionProjections.stateOf(enabled.agent.session, 'subagentModelSelectionPolicy')).toEqual([ + { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + ]) expect(toolNames(ctx, enabled.agent)).toContain('list_subagent_models') expect(toolParameterNames(ctx, enabled.agent, 'subagent')).toEqual(expect.arrayContaining([ 'model', 'provider', 'reasoning_effort', ])) - expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') + for (const agent of [disabled.agent, enabled.agent]) { + expect(toolParameterNames(ctx, agent, 'subagent_fork')).toEqual(['cwd', 'description', 'prompt']) + } } finally { await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) await enabled.dispose() diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index c442c84f56..e79792f570 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.2", + "version": "0.2.1-rc.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts index 13941060ef..7ed14dd3b3 100644 --- a/apps/desktop-host/src/office.ts +++ b/apps/desktop-host/src/office.ts @@ -21,7 +21,7 @@ export interface Config { } /** - * Enable offline Office authoring and structural checks in the Desktop profile. + * Enable offline Office authoring and LibreOffice Kit operations in the Desktop profile. * @param ctx - Profile scope; child plugins declare their own service requirements. * @param config - Bundled payload source and Harness-home installation root. */ diff --git a/apps/desktop/installer/directory-report.h b/apps/desktop/installer/directory-report.h new file mode 100644 index 0000000000..49f9cd59da --- /dev/null +++ b/apps/desktop/installer/directory-report.h @@ -0,0 +1,25 @@ +// Directory replacement failures retain the original Windows error and the independent restoration outcome. +#pragma once +#include "extract-report.h" + +namespace directory_report { + +// The recovery path stays complete in the dialog even when its report excerpt is truncated. +inline std::wstring Content(DWORD error, LPCWSTR hint, DWORD restoreError, LPCWSTR backup) { + std::wstring content = std::to_wstring(error) + L": " + extract_report::Win32Message(error) + L"\r\n\r\n" + hint; + if (restoreError) content += L"\r\n\r\n" + std::wstring(backup); + return content; +} + +inline std::wstring Compose(DWORD error, LPCWSTR source, LPCWSTR destination, DWORD restoreError, LPCWSTR backup, const std::wstring& attempts) { + const std::wstring operation = *destination ? L"MoveFileExW" : L"RemoveDirectoryW"; + std::wstring report = L"Operation: " + operation + L"\r\nWindows error: " + std::to_wstring(error) + + L" (" + extract_report::Win32Message(error) + L")\r\nSource: " + source + + L"\r\nDestination: " + destination + L"\r\nRestoration error: " + std::to_wstring(restoreError); + if (restoreError) report += L" (" + extract_report::Win32Message(restoreError) + L")\r\nPreserved backup: " + backup; + report += L"\r\nTime: " + extract_report::Timestamp() + L"\r\nWindows: " + extract_report::WindowsVersion() + + L"\r\nAttempts:\r\n" + attempts; + return report; +} + +} diff --git a/apps/desktop/installer/extract-report.h b/apps/desktop/installer/extract-report.h index c885816c75..cdfbbad22b 100644 --- a/apps/desktop/installer/extract-report.h +++ b/apps/desktop/installer/extract-report.h @@ -130,16 +130,8 @@ inline std::wstring Compose(int code, const std::wstring& archive, const std::ws return report; } -// The expanded panel adds 7-Zip's own lines beneath the result; long lines are cut and a trailing note counts what the -// saved report still holds. Header fields already appear in the report file and clipboard. -inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) { - std::vector lines; - bool inOutput = false; - for (const std::wstring& line : Lines(Trim(report))) { - if (inOutput) lines.push_back(line); - else if (line.rfind(L"Result: ", 0) == 0) lines.push_back(line); - else if (line == kOutputMarker) inOutput = true; - } +// Expanded native dialogs bound both line count and line length; files and clipboard keep the complete report. +inline std::wstring BoundedExcerpt(const std::vector& lines, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) { std::wstring excerpt; size_t shown = 0; for (const std::wstring& line : lines) { @@ -154,6 +146,18 @@ inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcer return excerpt; } +// The extraction panel selects 7-Zip's own lines beneath the result; metadata remains in the saved report. +inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) { + std::vector lines; + bool inOutput = false; + for (const std::wstring& line : Lines(Trim(report))) { + if (inOutput) lines.push_back(line); + else if (line.rfind(L"Result: ", 0) == 0) lines.push_back(line); + else if (line == kOutputMarker) inOutput = true; + } + return BoundedExcerpt(lines, maxLines, maxChars); +} + // A cut inside a multi-byte sequence would make the whole UTF-8 attempt fail; drop the incomplete tail first. inline void TrimPartialUtf8(std::string& bytes) { size_t trailing = 0; diff --git a/apps/desktop/installer/strings.nsh b/apps/desktop/installer/strings.nsh index 174ebfa28b..a1ac41b418 100644 --- a/apps/desktop/installer/strings.nsh +++ b/apps/desktop/installer/strings.nsh @@ -29,6 +29,12 @@ LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。" LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Quit it from the system tray and try again." LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。" +LangString INSTALLER_DIRECTORY_FAILED ${LANG_ENGLISH} "The installation directory could not be replaced." +LangString INSTALLER_DIRECTORY_FAILED ${LANG_SIMPCHINESE} "无法替换安装目录。" +LangString INSTALLER_DIRECTORY_HINT ${LANG_ENGLISH} "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists." +LangString INSTALLER_DIRECTORY_HINT ${LANG_SIMPCHINESE} "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。" +LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_ENGLISH} "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again." +LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_SIMPCHINESE} "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。" LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported." LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。" LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder." diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index 001fe57a29..04d258357d 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -13,10 +13,34 @@ #include "progress.h" #include "extract.h" #include "extract-report.h" +#include "directory-report.h" #include "uninstall-data.h" using namespace Gdiplus; +// Returns whether the UTF-8 report was saved; failure still presents the original error unless silent. +extern "C" __declspec(dllexport) int __cdecl InstallerReportDirectoryFailure( + HWND parent, DWORD error, LPCWSTR source, LPCWSTR destination, DWORD restoreError, LPCWSTR backup, + LPCWSTR log, LPCWSTR reportPath, int show, LPCWSTR title, LPCWSTR heading, LPCWSTR hint, LPCWSTR copy, + LPCWSTR expand, LPCWSTR collapse, LPCWSTR savedFormat, LPCWSTR unsaved, LPCWSTR copied) { + try { + const std::wstring report = directory_report::Compose(error, source, destination, restoreError, backup, extract_report::ReadOutput(log)); + const bool saved = extract_report::WriteUtf8(reportPath, report); + if (show) { + std::wstring footer = saved ? savedFormat : unsaved; + const auto placeholder = footer.find(L"%s"); + if (saved && placeholder != std::wstring::npos) footer.replace(placeholder, 2, reportPath); + const extract_report::DialogStrings strings{title, heading, copy, expand, collapse, footer.c_str(), copied}; + const std::wstring content = directory_report::Content(error, hint, restoreError, backup); + extract_report::Show(parent, content, extract_report::BoundedExcerpt(extract_report::Lines(extract_report::Trim(report))), report, strings); + } + return saved ? 1 : 0; + } catch (const std::bad_alloc&) { + if (show) MessageBoxW(parent, hint, heading, MB_OK | MB_ICONEXCLAMATION); + return 0; + } +} + // Saves the extraction failure report and, unless silent, presents its headline with a copy action. // Returns 1 when the report file was written and 0 otherwise; the dialog outcome never fails the caller. extern "C" __declspec(dllexport) int __cdecl InstallerReportExtractFailure( diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 34e859cd20..1d44f92b93 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.2", + "version": "0.2.1-rc.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 40103064d6..a2cdac0936 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,8 +24,8 @@ en: f5dc36baf414ecc5 zh: 8984741b356ac1d3 /desktop-packaging/windows-actions-packaging: - en: e3f51e7130a0fe05 - zh: 70fd6a40d5899871 + en: e1b101593f6f755e + zh: 121f225de399de1e /desktop-packaging/macos-self-hosted-packaging: en: a3af9523fdc60036 zh: 5e18d4740e9b7f57 @@ -45,8 +45,8 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 06d5c666a3e7f2e4 - zh: c1d4a55cc29e11e3 + en: bfd89e1d139c5729 + zh: 5d6cced5462ba13e /desktop-packaging/desktop-runtime-preparation: en: a7aa3066e649ffe8 zh: 4334637910058c09 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 9cf46b68e1..b29bc9dcc2 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -103,7 +103,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t ## Windows Actions packaging -The `Package Desktop · Windows` [manual 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. +For supervised runner startup and recovery, follow [Windows runner machine setup](../windows-signing/README.md#machine-setup). + +The `Package Desktop · Windows` [manual 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 `signed` (default) or `unsigned`; 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. @@ -244,6 +246,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis 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. +Directory renames, empty-destination removal, and restoration retry Windows errors 5, 32, and 33 up to ten attempts, with 250 ms increasing delays (11.25 seconds of total waiting per operation). Other errors stop immediately. A replacement failure reports the Windows error, affected paths, and restoration outcome in `directory-failure-.log` beside extraction reports. If restoration fails, the installer removes unlocked staging files and preserves the backup. After reporting that failure, exit cleanup does not retry restoration. Keep the backup path named in the report and contact support before reinstalling. Silent installs save the same diagnostic without a dialog. + 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. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 125b480ce5..e97f072a2f 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -103,7 +103,9 @@ macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../ ## Windows Actions 打包 -`Package Desktop · 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 保持不变。 +runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-signing/README.zh.md#machine-setup)。 + +`Package Desktop · Windows` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 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 之外,不作为产物上传。 @@ -244,6 +246,8 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 +目录改名、空目标目录删除和旧版恢复遇到 Windows 错误 5、32 或 33 时最多尝试十次,每次等待递增 250 毫秒(每个操作合计等待 11.25 秒)。其他错误立即停止。替换失败时,安装器将 Windows 错误、相关路径和恢复结果写入解压报告旁的 `directory-failure-<时间戳>.log`。如果恢复失败,安装器会清理未被占用的暂存文件并保留备份。上报此失败后,退出清理不再重试恢复。请保留报告所列的备份目录,并在重新安装前联系支持人员。静默安装保存相同的诊断,不显示弹窗。 + 解压失败时,安装器会把 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 过滤会生成该解码器无法解压的条目。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index d89cc5e4f8..a16c1bc513 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -15,14 +15,14 @@ en: 1b6a4d7146879eec zh: f2520fc412cd2d2a /desktop-ci-publication/machine-setup: - en: 990ff7e56dd6005a - zh: 18f9dc23c520dc8c + en: f80388c2b1ffef4c + zh: f242ab2316bf38a1 /desktop-ci-publication/package-and-publish: - en: c17307a96efdde3a - zh: 53ba328b149ed407 + en: 0a26537845e89dda + zh: 307378bb65e24650 /desktop-ci-publication/recovery-and-records: - en: e92bfad06162443e - zh: 6c3e3d8ece673217 + en: 2e1e6b16d0d6bd41 + zh: 6ff4ad5a8f1f57b4 /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index f3d50a61a3..0028260f96 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -23,7 +23,7 @@ Publish a completed package from its original machine without downloading its in Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging directory. Completed attempts are copied to its sibling `releases///`; `DSH_DESKTOP_RELEASES_DIR` overrides that root with an absolute path. The inventory includes installers, update archives, blockmaps, completion metadata and a credential-free manifest. Debug packages are retained but cannot publish. Copies are checked before the attempt directory becomes visible, and an existing attempt cannot be replaced. GitHub retains the same file inventory for 14 days. Local files remain until an operator removes them; do not delete an attempt while it is being verified or published. -Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Bucket versioning must be disabled for immutable binary writes. +Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration. Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. @@ -35,11 +35,13 @@ Environment approval gates this workflow, not arbitrary code running under the s ## Package and publish -Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts `master` or `dsh-v*` matching the selected product version; the selected source must contain these CI adapters, so older tags fail before dependency installation. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. +For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes. + +Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. -Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes an approval summary with version, channel, platform, deployment, source ref/SHA, package run/attempt, object keys and proposed tag. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. +Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. @@ -47,7 +49,7 @@ Before uploading, the publisher hashes all retained files, checks version and de After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. -The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; raw provider responses are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. +The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 97bb986b67..c6165845b5 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -23,7 +23,7 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安 保持 `DSH_PACKAGING_CONFIG_DIR` 指向现有机器打包目录。完成的构建复制到其同级 `releases///`;`DSH_DESKTOP_RELEASES_DIR` 可用绝对路径覆盖该根目录。文件清单包含安装包、更新归档、blockmap、完成记录和不含凭据的 manifest。调试包会留存,但不能发布。复制内容通过校验后才开放 attempt 目录,已有 attempt 不能被替换。GitHub 保留相同文件清单 14 天。本地文件保留到操作员删除;校验或发布期间不要删除对应 attempt。 -在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。不可覆盖的二进制写入要求 bucket 版本控制关闭。 +在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。 将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 @@ -35,11 +35,13 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 打包与发布 -选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。`source_ref` 接受 `master` 或与所选产品版本一致的 `dsh-v*`;源码必须包含这些 CI 适配脚本,因此旧 tag 会在安装依赖前失败。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 +发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。 + +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 -上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成审批摘要,列出版本、通道、平台、环境、源码 ref/SHA、package run/attempt、对象路径及拟创建的 tag。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 +上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 @@ -47,7 +49,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 -同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断,不保存原始服务响应。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 +同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。 diff --git a/apps/desktop/scripts/desktop-publication-failure.ts b/apps/desktop/scripts/desktop-publication-failure.ts index 8f3e6beb55..930c23da2d 100644 --- a/apps/desktop/scripts/desktop-publication-failure.ts +++ b/apps/desktop/scripts/desktop-publication-failure.ts @@ -5,13 +5,40 @@ import { DesktopPublishError } from './desktop-publish.ts' const publicCodes = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch', 'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT']) +/** COS operation failure retaining its cause for safe diagnostics; a failed write may have an unknown remote outcome. */ +export class DesktopCosOperationError extends Error { + constructor(readonly operation: 'GetObject' | 'PutObject', cause: unknown) { + const failure = desktopPublicationFailure(cause, operation) + const message = failure.code === 'AccessDenied' + ? `AccessDenied. Check name/cos:${operation} permission and explicit deny rules for the selected bucket/object. Publication stopped; reconcile remote state before retrying.` + : failure.message + super(`COS ${operation}: ${message}`, { cause }) + this.name = 'DesktopCosOperationError' + } +} + /** * Keep actionable local validation details without recording SDK response bodies or credentials. * @param error Failure from validation, filesystem, subprocess or remote transport. * @param phase Operation stage known by the entrypoint. * @returns Credential-free diagnostic suitable for stderr and an audit event. */ -export function desktopPublicationFailure(error: unknown, phase: string): { phase: string; code: string; message: string } { +export function desktopPublicationFailure(error: unknown, phase: string): { + phase: string + code: string + message: string + operation?: 'GetObject' | 'PutObject' + requestId?: string + httpStatus?: number +} { + if (error instanceof DesktopCosOperationError) { + const cause = typeof error.cause === 'object' && error.cause !== null ? error.cause : {} + const requestId = 'RequestId' in cause ? cause.RequestId : undefined + const status = 'statusCode' in cause ? cause.statusCode : undefined + return { ...desktopPublicationFailure(error.cause, phase), operation: error.operation, message: error.message, + ...(typeof requestId === 'string' && /^[\w+/=.-]{1,256}$/u.test(requestId) ? { requestId } : {}), + ...(typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}) } + } if (error instanceof DesktopPublishError) return { phase, code: error.code, message: error.message } if (error instanceof ZodError) return { phase, code: 'invalid-data', message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` } diff --git a/apps/desktop/scripts/desktop-publication-summary.ts b/apps/desktop/scripts/desktop-publication-summary.ts new file mode 100644 index 0000000000..4d2d437dd3 --- /dev/null +++ b/apps/desktop/scripts/desktop-publication-summary.ts @@ -0,0 +1,71 @@ +/** Render the verified publication plan for GitHub's native deployment reviewers. */ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import type { DesktopRetainedSource } from './desktop-release-store.ts' +import type { DesktopUploadPlan } from './desktop-upload-plan.ts' + +function text(value: string): string { + return value.replace(/[&<>"'|`\r\n\\*_\[\]()!~:]/gu, character => `&#${character.charCodeAt(0)};`) +} + +function code(value: string): string { return `${text(value)}` } +function row(label: string, value: string): string { return `| ${label} | ${value} |` } +function link(url: string, label = url): string { return `${text(label)}` } + +/** + * Render Chinese approval details after package, destination and signature verification succeeds. + * @param source Verified retained packaging identity; a test plan requires its validated release ID. + * @param plan Validated upload plan; YAML contents are the bytes that will be uploaded. + * @param approval Digest bound to the approved manifest and upload plan. + * File SHA-256 hex supports `shasum -a 256` on downloads; upload reconciliation separately uses SHA-512 base64. + * @returns GitHub-flavored Markdown with final URLs and upload-byte fingerprints, without credentials or local paths. + */ +export async function desktopPublicationSummary( + source: DesktopRetainedSource, plan: DesktopUploadPlan, approval: string, +): Promise { + const origin = new URL(plan.publicUrl).origin + const url = (key: string) => `${origin}/${key.split('/').map(encodeURIComponent).join('/')}` + const files = [] + for (const file of plan.artifacts) { + const hash = createHash('sha256') + let size = 0 + const bytes = file.contents === undefined ? createReadStream(file.path) : [Buffer.from(file.contents)] + for await (const chunk of bytes) { size += chunk.length; hash.update(chunk) } + files.push(row(link(url(file.key), file.filename), `${size} 字节 | ${code(hash.digest('hex'))}`)) + } + const installerExtension = plan.target === 'win-x64' ? '.exe' : '.dmg' + const installers = plan.artifacts.filter(file => !file.channelMetadata && file.filename.endsWith(installerExtension)) + const feeds = plan.artifacts.filter(file => file.channelMetadata) + const runUrl = `https://github.com/${source.repository}/actions/runs/${source.runId}/attempts/${source.attempt}` + const environment = plan.environment === 'test' ? 'desktop-test' : 'desktop-production' + const tag = plan.environment === 'production' ? code(`desktop-v${plan.version}`) : '不创建(test 环境)' + return [ + '', '## 发布前确认', '', + `核对以下信息后,点击 **Review deployments**,选择 ${code(environment)} 并点击 **Approve and deploy**;信息有误请点击 **Reject**。`, '', + '| 项目 | 发布信息 |', '| --- | --- |', + row('发布环境', code(plan.environment)), + row('版本号', code(plan.version)), + row('升级通道', code(source.channel)), + row('平台', code(plan.target)), + ...(plan.environment === 'test' ? [row('测试 release ID', `${code(source.testReleaseId!)}
测试下载路径的随机前缀,用于降低地址被猜到的可能性;连续升级测试需使用相同值。`)] : []), + row('下载地址预览', `${installers.map(file => link(url(file.key))).join('
')}
发布后的目标地址,审批前不保证可下载。`), + row('升级 YAML', `${feeds.map(file => link(url(file.key))).join('
')}
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。`), + row('打包来源', `${link(runUrl, `查看打包运行 #${source.runId} · 第 ${source.attempt} 次尝试`)}
源码:${code(`${source.sourceRef} @ ${source.commit}`)}
打包机器:${code(source.runnerName)}`), + row('签名与公证', plan.target === 'win-x64' ? 'EXE 签名校验通过' : 'DMG 校验通过
ZIP 内应用校验通过'), + row('Git tag', tag), '', '### 批准后将执行', '', + '1. 重新校验产物与发布目标;若与审批摘要不一致则停止。', + '2. 上传缺少的文件,跳过内容一致的已有文件;发现内容冲突则停止。', + '3. 更新上述升级 YAML,并保存发布记录。', + ...(plan.environment === 'production' ? ['4. 为实际打包提交创建上述 Git tag;若 tag 创建失败,记录“已发布,待补 tag”。'] : []), '', + ...(plan.environment === 'test' ? ['**本次只发布 test,不修改 production。**', ''] : []), + '
', '技术详情与文件清单', '', + `COS 存储桶:${code(plan.bucket)}`, '', + ...(plan.environment === 'test' ? [ + 'release ID 取自原打包产物清单。打包时优先使用表单 `test_release_id`;留空则使用机器 `presets/test.env` 中的 `DOWNLOAD_TEST_RELEASE_ID`。发布时不重新选择。', '', + ] : []), + `审批摘要 SHA-256:${code(approval)}`, '', + `共 ${plan.artifacts.length} 个文件;大小及 SHA-256 对应实际上传字节,包括更新后的 YAML 内容。`, '', + '| 文件 | 大小 | SHA-256 |', '| --- | --- | --- |', ...files, '', '
', '', + '若仓库为公开仓库,此摘要中的下载路径及 test 发布的 release ID 也会公开。随机前缀不能替代访问鉴权。', '', + ].join('\n') +} diff --git a/apps/desktop/scripts/desktop-publish-cos.ts b/apps/desktop/scripts/desktop-publish-cos.ts index 5b89a8ef40..00a6f108d1 100644 --- a/apps/desktop/scripts/desktop-publish-cos.ts +++ b/apps/desktop/scripts/desktop-publish-cos.ts @@ -7,6 +7,7 @@ import { createDesktopCos, DESKTOP_COS_REGION, type DesktopCosCredentials } from import { cosOperation } from './cos-operation.ts' import type { DesktopUploadPlan } from './desktop-upload-plan.ts' import { desktopArtifactDigest, type DesktopPublishTransport } from './desktop-publish.ts' +import { DesktopCosOperationError } from './desktop-publication-failure.ts' function missing(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'NoSuchKey' @@ -14,6 +15,7 @@ function missing(error: unknown): boolean { /** * Create a bounded COS transport restricted to the validated plan's object keys. + * Administrators must keep bucket versioning absent; publication does not query bucket configuration. * @param plan Destination and exact object inventory; arbitrary keys are rejected. * @param credentials Credentials for this deployment only. * @param createClient Client factory; absent uses the shared production client, tests supply a loopback client. @@ -51,7 +53,7 @@ export function desktopPublishCos( return { size, sha512: sha512.digest('base64'), ...(channel ? { contents: Buffer.concat(chunks).toString('utf8') } : {}) } } catch (error) { if (missing(error)) return null - throw error + throw new DesktopCosOperationError('GetObject', error) } finally { output.destroy() } }, async put(artifact, digest, forbidOverwrite) { @@ -62,13 +64,6 @@ export function desktopPublishCos( const actual = await desktopArtifactDigest(artifact) if (actual.size !== digest.size || actual.sha512 !== digest.sha512) throw new Error('desktop publish: local input changed') const cos = client() - // COS overwrite prevention requires versioning to be absent, not enabled or suspended. - if (forbidOverwrite) { - const state = await cosOperation(cos, 30_000, () => cos.getBucketVersioning({ Bucket: plan.bucket, Region: DESKTOP_COS_REGION })) - if (state.statusCode !== 200 || state.VersioningConfiguration.Status !== undefined) { - throw new Error('desktop publish: immutable objects require bucket versioning to be disabled') - } - } const md5 = createHash('md5') const bytes = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)] for await (const chunk of bytes) md5.update(chunk) @@ -78,6 +73,8 @@ export function desktopPublishCos( ...address, Body: body, ContentLength: digest.size, ContentType: artifact.contentType, Headers: { 'Content-MD5': md5.digest('base64'), ...(forbidOverwrite ? { 'x-cos-forbid-overwrite': 'true' } : {}) }, })) + } catch (error) { + throw new DesktopCosOperationError('PutObject', error) } finally { body.destroy() } }, } diff --git a/apps/desktop/scripts/desktop-publish-qualification.d.mts b/apps/desktop/scripts/desktop-publish-qualification.d.mts new file mode 100644 index 0000000000..b72cb33d02 --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-qualification.d.mts @@ -0,0 +1,7 @@ +/** Explain why retained debugging packages cannot enter either publication environment. */ +/** + * Return rebuilding instructions for debugging packages. + * @param qualification Recorded package qualification. + * @returns Diagnostic, or undefined for a non-debugging qualification. + */ +export function desktopPublishQualificationError(qualification: string): string | undefined diff --git a/apps/desktop/scripts/desktop-publish-qualification.mjs b/apps/desktop/scripts/desktop-publish-qualification.mjs new file mode 100644 index 0000000000..05eae5ffbf --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-qualification.mjs @@ -0,0 +1,15 @@ +/** Explain why retained debugging packages cannot enter either publication environment. */ + +/** + * Return rebuilding instructions for debugging packages. + * @param {string} qualification Recorded package qualification. + * @returns {string | undefined} Diagnostic, or undefined for a non-debugging qualification. + */ +export function desktopPublishQualificationError(qualification) { + const remedy = qualification === 'unsigned' + ? '该包未签名(unsigned)。请重新运行 Windows 打包并选择 signing=signed。' + : qualification === 'unnotarized' + ? '该包跳过了公证(unnotarized)。请重新运行 macOS 打包,取消勾选“跳过公证”,完成公证和 stapling。' + : undefined + return remedy && `desktop publish: ${remedy}当前产物仅限内部调试,不能发布到 test 或 production,也不能用于自动更新。打包成功后使用新的 run ID / attempt 发布;重跑当前发布任务无效。` +} diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index 8914090284..303c447040 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -1,5 +1,6 @@ /** Validate a completed packaging attempt and route publication back to its original runner. */ import { z } from 'zod' +import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' const positive = z.number().int().positive() const runSchema = z.object({ @@ -9,11 +10,12 @@ const runSchema = z.object({ }) const requestSchema = z.object({ source: z.object({ repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), - workflowCommit: z.string(), sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + workflowCommit: z.string(), sourceRef: z.string().min(1), channel: z.enum(['nightly', 'latest']), runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']), - qualification: z.enum(['signed', 'notarized']), -}) }) + qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), +}).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef), + 'Production source must be master or dsh-v*') }) const jobsSchema = z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable() })) const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) })) @@ -44,6 +46,8 @@ export function resolveDesktopPublishRoute( ): DesktopPublishRoute { const run = runSchema.parse(runValue) const { source } = requestSchema.parse(requestValue) + const diagnostic = desktopPublishQualificationError(source.qualification) + if (diagnostic) throw new Error(diagnostic) const jobs = jobsSchema.parse(jobsValue) const routes = routesSchema.parse(routesValue) const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml' diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts index 1c9ff23951..c080272e1d 100644 --- a/apps/desktop/scripts/desktop-release-store.ts +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -13,7 +13,7 @@ const sourceSchema = z.strictObject({ attempt: positiveId, commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u), - sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + sourceRef: z.string().min(1), channel: z.enum(['nightly', 'latest']), runnerName: z.string().min(1), // Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms. @@ -25,7 +25,8 @@ const sourceSchema = z.strictObject({ // Older retained manifests carry this flag; manual publication does not depend on it. publishRequested: z.boolean().optional(), qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), -}) +}).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef), + 'Production source must be master or dsh-v*') const manifestSchema = z.strictObject({ schemaVersion: z.literal(1), source: sourceSchema, diff --git a/apps/desktop/scripts/installer-directories.nsh b/apps/desktop/scripts/installer-directories.nsh index 49d4a2fb82..e189f787f8 100644 --- a/apps/desktop/scripts/installer-directories.nsh +++ b/apps/desktop/scripts/installer-directories.nsh @@ -5,6 +5,71 @@ Var dshNewDirectory Var dshOldDirectory Var dshOldMoved Var dshNewMoved +Var dshDirectoryError +Var dshDirectorySource +Var dshDirectoryDestination +Var dshRestoreError +Var dshDirectoryLogDirectory +Var dshRecoveryStopped + +; MoveFileEx preserves the destination and exposes the Windows error before rollback changes it. +; Open files inside a directory can make its rename return access denied (5). +!macro dshDirectoryOperation SOURCE DESTINATION RESULT PHASE + Push $0 + Push $1 + Push $2 + StrCpy $0 0 + ${Do} + ${If} "${DESTINATION}" == "" + System::Call 'kernel32::RemoveDirectoryW(w "${SOURCE}") i.r1 ?e' + ${Else} + System::Call 'kernel32::MoveFileExW(w "${SOURCE}", w "${DESTINATION}", i 0) i.r1 ?e' + ${EndIf} + Pop ${RESULT} + ${If} "${DESTINATION}" == "" + ${If} ${RESULT} == 2 + ${OrIf} ${RESULT} == 3 + StrCpy $1 1 + ${EndIf} + ${EndIf} + ${If} $1 != 0 + StrCpy ${RESULT} 0 + ${ExitDo} + ${EndIf} + ClearErrors + FileOpen $2 "$PLUGINSDIR\directory-operations.log" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "${PHASE}: Windows error: ${RESULT}$\r$\n" + FileClose $2 + ${EndIf} + ${If} ${RESULT} != 5 + ${AndIf} ${RESULT} != 32 + ${AndIf} ${RESULT} != 33 + ${ExitDo} + ${EndIf} + IntOp $0 $0 + 1 + ${If} $0 >= 10 + ${ExitDo} + ${EndIf} + IntOp $1 $0 * 250 + Sleep $1 + ${Loop} + Pop $2 + Pop $1 + Pop $0 +!macroend + +!macro dshReportDirectoryFailure + ${If} $dshRestoreError != 0 + StrCpy $dshRecoveryStopped "1" + ${EndIf} + !ifmacrodef customInstallerDirectoryFailed + !insertmacro customInstallerDirectoryFailed + !else + DetailPrint "Directory operation failed ($dshDirectoryError): $dshDirectorySource -> $dshDirectoryDestination; restore error: $dshRestoreError" + !endif +!macroend !macro dshExtractPayload FILE !ifmacrodef customInstallerExtract @@ -28,6 +93,10 @@ Var dshNewMoved !macroend !macro dshStageApplication + !ifdef DSH_INSTALLER_LOG_DIR + ; electron-builder defines the cache name after including our function bodies, before this section expands. + StrCpy $dshDirectoryLogDirectory "${DSH_INSTALLER_LOG_DIR}" + !endif StrCpy $dshFinalDirectory $INSTDIR System::Call 'ole32::CoCreateGuid(g .r0) i .r1' ${If} $1 != 0 @@ -38,6 +107,9 @@ Var dshNewMoved StrCpy $dshOldDirectory "$INSTDIR.old-$0" StrCpy $dshOldMoved "" StrCpy $dshNewMoved "" + StrCpy $dshDirectoryError 0 + StrCpy $dshRestoreError 0 + StrCpy $dshRecoveryStopped "" ClearErrors CreateDirectory $dshNewDirectory ${If} ${Errors} @@ -64,6 +136,9 @@ Function .onGUIEnd FunctionEnd Function dshCleanupDirectories + ${If} $dshRecoveryStopped == "1" + Return + ${EndIf} ${If} $dshFinalDirectory != "" Call dshRollbackDirectories ${EndIf} @@ -77,11 +152,11 @@ Function dshRollbackDirectories StrCpy $dshNewMoved "" ${EndIf} ${If} $dshOldMoved == "1" - ClearErrors - Rename $dshOldDirectory $dshFinalDirectory - ${If} ${Errors} + !insertmacro dshDirectoryOperation "$dshOldDirectory" "$dshFinalDirectory" $dshRestoreError "restore" + ${If} $dshRestoreError != 0 ; Leave the complete backup in place if another process prevents restoration. DetailPrint $dshOldDirectory + RMDir /r "\\?\$dshNewDirectory" Return ${EndIf} StrCpy $dshOldMoved "" @@ -100,21 +175,35 @@ Function dshPromoteDirectories SetOutPath $PLUGINSDIR ClearErrors ${If} ${FileExists} "$dshFinalDirectory\*.*" - Rename $dshFinalDirectory $dshOldDirectory - ${If} ${Errors} + StrCpy $dshDirectorySource $dshFinalDirectory + StrCpy $dshDirectoryDestination $dshOldDirectory + !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError "backup" + ${If} $dshDirectoryError != 0 Call dshRollbackDirectories + !insertmacro dshReportDirectoryFailure SetErrors Return ${EndIf} StrCpy $dshOldMoved "1" ${Else} ; NSIS can create the destination before the install section starts. - RMDir $dshFinalDirectory + StrCpy $dshDirectorySource $dshFinalDirectory + StrCpy $dshDirectoryDestination "" + !insertmacro dshDirectoryOperation "$dshDirectorySource" "" $dshDirectoryError "remove-empty" + ${If} $dshDirectoryError != 0 + Call dshRollbackDirectories + !insertmacro dshReportDirectoryFailure + SetErrors + Return + ${EndIf} ${EndIf} ClearErrors - Rename $dshNewDirectory $dshFinalDirectory - ${If} ${Errors} + StrCpy $dshDirectorySource $dshNewDirectory + StrCpy $dshDirectoryDestination $dshFinalDirectory + !insertmacro dshDirectoryOperation "$dshDirectorySource" "$dshDirectoryDestination" $dshDirectoryError "promote" + ${If} $dshDirectoryError != 0 Call dshRollbackDirectories + !insertmacro dshReportDirectoryFailure SetErrors Return ${EndIf} diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index 0f4d859e78..5961c071ba 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -195,6 +195,37 @@ ManifestDPIAware true ${EndIf} !macroend +!macro customInstallerDirectoryFailed + Push $0 + Push $1 + Push $2 + Push $3 + Push $4 + Push $5 + Push $6 + ${GetTime} "" "L" $0 $1 $2 $3 $4 $5 $6 + StrCpy $0 "$dshDirectoryLogDirectory\directory-failure-$2$1$0-$4$5$6.log" + StrCpy $1 1 + ${If} ${Silent} + StrCpy $1 0 + ${EndIf} + StrCpy $3 "$(INSTALLER_DIRECTORY_HINT)" + ${If} $dshRestoreError != 0 + StrCpy $3 "$(INSTALLER_DIRECTORY_RESTORE_HINT)" + ${EndIf} + System::Call '$PLUGINSDIR\window-frame.dll::InstallerReportDirectoryFailure(p $HWNDPARENT, i $dshDirectoryError, w "$dshDirectorySource", w "$dshDirectoryDestination", i $dshRestoreError, w "$dshOldDirectory", w "$PLUGINSDIR\directory-operations.log", w r0, i r1, w "$(^SetupCaption)", w "$(INSTALLER_DIRECTORY_FAILED)", w r3, w "$(INSTALLER_EXTRACT_COPY)", w "$(INSTALLER_EXTRACT_EXPAND)", w "$(INSTALLER_EXTRACT_COLLAPSE)", w "$(INSTALLER_EXTRACT_SAVED)", w "$(INSTALLER_EXTRACT_UNSAVED)", w "$(INSTALLER_EXTRACT_COPIED)") i.r2 ?c' + ${If} $2 == 1 + DetailPrint $0 + ${EndIf} + Pop $6 + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 +!macroend + !ifndef BUILD_UNINSTALLER !include "${__FILEDIR__}\installer-directories.nsh" !endif diff --git a/apps/desktop/scripts/preflight-desktop-publish.d.mts b/apps/desktop/scripts/preflight-desktop-publish.d.mts new file mode 100644 index 0000000000..84b300c61b --- /dev/null +++ b/apps/desktop/scripts/preflight-desktop-publish.d.mts @@ -0,0 +1,10 @@ +/** Reject identifiable debugging artifacts before installing publication dependencies. */ +/** + * Pin the requested attempt and reject its explicitly named debugging artifacts. + * @param repository GitHub repository. + * @param inputs Dispatch inputs. + * @param api Parsed GitHub JSON. + * @returns Exact attempt for subsequent routing; local verification remains required. + */ +export function preflightDesktopPublish(repository: string, inputs: { package_run_id: string; package_run_attempt?: string }, + api: (path: string, paginate?: boolean) => unknown): string diff --git a/apps/desktop/scripts/preflight-desktop-publish.mjs b/apps/desktop/scripts/preflight-desktop-publish.mjs new file mode 100644 index 0000000000..3d9624b20f --- /dev/null +++ b/apps/desktop/scripts/preflight-desktop-publish.mjs @@ -0,0 +1,47 @@ +/** Reject identifiable debugging artifacts before installing publication dependencies. */ +import { execFileSync } from 'node:child_process' +import { appendFile, readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' + +/** + * Pin the requested attempt and reject its explicitly named debugging artifacts. + * Artifact names only reject; local inventory and signature verification still authorize publication. + * @param {string} repository GitHub repository. + * @param {{ package_run_id: string, package_run_attempt?: string }} inputs Dispatch inputs. + * @param {(path: string, paginate?: boolean) => unknown} api Parsed GitHub JSON; validated here without dependencies. + * @returns {string} Exact attempt for subsequent routing. + */ +export function preflightDesktopPublish(repository, inputs, api) { + if (!/^[\w.-]+\/[\w.-]+$/u.test(repository) || !/^[1-9]\d*$/u.test(inputs.package_run_id) + || (inputs.package_run_attempt && !/^[1-9]\d*$/u.test(inputs.package_run_attempt))) { + throw new Error('desktop publish: repository, package_run_id and package_run_attempt must identify a valid packaging attempt') + } + const base = `repos/${repository}/actions/runs/${inputs.package_run_id}` + const latest = inputs.package_run_attempt ? undefined : api(base) + const attempt = inputs.package_run_attempt || String(latest && typeof latest === 'object' && 'run_attempt' in latest ? latest.run_attempt : '') + if (!/^[1-9]\d*$/u.test(attempt)) throw new Error('desktop publish: GitHub returned an invalid attempt') + const pages = api(`${base}/artifacts?per_page=100`, true) + if (!Array.isArray(pages) || pages.some(page => !Array.isArray(page.artifacts) + || page.artifacts.some(artifact => typeof artifact.name !== 'string'))) { + throw new Error('desktop publish: GitHub returned invalid artifact metadata') + } + for (const { name } of pages.flatMap(page => page.artifacts)) { + if (!name.endsWith(`-${inputs.package_run_id}-${attempt}`)) continue + const qualification = name.startsWith('windows-x64-unsigned-') ? 'unsigned' + : name.startsWith('macos-arm64-unnotarized-debug-') ? 'unnotarized' : '' + const diagnostic = desktopPublishQualificationError(qualification) + if (diagnostic) throw new Error(diagnostic) + } + return attempt +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + const env = process.env + if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT) throw new Error('desktop publish: Actions context required') + const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8')) + const attempt = preflightDesktopPublish(env.GITHUB_REPOSITORY, event.inputs, (path, paginate) => + JSON.parse(execFileSync('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), path], + { encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 }))) + await appendFile(env.GITHUB_OUTPUT, `attempt=${attempt}\n`) +} diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index 696ceea2cd..4b9c3e77d1 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -12,7 +12,9 @@ import { desktopPublishCos } from './desktop-publish-cos.ts' import { createDesktopPublicationRecord, wasDesktopAttemptPublished, withDesktopChannelLock } from './desktop-publish-record.ts' import { publishDesktopRelease } from './desktop-publish.ts' import { desktopPublicationFailure } from './desktop-publication-failure.ts' +import { desktopPublicationSummary } from './desktop-publication-summary.ts' import { publishDesktopTag } from './desktop-publish-tag.ts' +import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), sourceRef: z.string(), channel: z.enum(['nightly', 'latest']), @@ -35,7 +37,9 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi || source.preset !== route.preset) { throw new Error('desktop publish: local package differs from the routed request') } - if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: debugging artifacts cannot be published') + const diagnostic = desktopPublishQualificationError(source.qualification) + if (diagnostic) throw new Error(diagnostic) + if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: package qualification does not match its platform') const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, source.preset) const directory = retainedDesktopPath(root, route.runId, route.attempt) const environment = source.preset === 'test' @@ -61,9 +65,8 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required') if (mode === '--check') { if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `approval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`) - if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, - `\n## Confirm Desktop publication\n\nVersion: **${plan.version}**; channel: **${manifest.source.channel}**; environment: **${plan.environment}**; platform: **${plan.target}**\n\nSource: ${manifest.source.sourceRef} @ ${route.commit}\n\nPackage: ${route.runId}, attempt ${route.attempt}\n\nTag: ${plan.environment === 'production' ? `desktop-v${plan.version}` : 'none (test)'}\n\nApproved package SHA-256: ${approval}\n\n` + - plan.artifacts.map(file => `- ${file.channelMetadata ? 'Feed' : 'Artifact'}: ${file.key}`).join('\n') + '\n') + if (env.GITHUB_STEP_SUMMARY && !env.DESKTOP_APPROVED_PACKAGE) await appendFile(env.GITHUB_STEP_SUMMARY, + await desktopPublicationSummary(manifest.source, plan, approval)) if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT, `app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`) process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`) diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts index 0657b70be6..ce39bc32bc 100644 --- a/apps/desktop/scripts/resolve-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -62,6 +62,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const env = process.env if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT) throw new Error('desktop publish: Actions context required') const event = z.object({ inputs: inputsSchema }).parse(JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'))) - const selected = selectDesktopPackage(env.GITHUB_REPOSITORY, event.inputs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}'), desktopPublishApi) + const inputs = { ...event.inputs, package_run_attempt: env.DESKTOP_PACKAGE_ATTEMPT || event.inputs.package_run_attempt } + const selected = selectDesktopPackage(env.GITHUB_REPOSITORY, inputs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}'), desktopPublishApi) await appendFile(env.GITHUB_OUTPUT, `selection=${JSON.stringify(selected)}\nrunner=${selected.runnerLabel}\n`) } diff --git a/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts index 6086b28cc7..d892fe6906 100644 --- a/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts @@ -22,11 +22,11 @@ export async function resolveRetainedDesktopPublication(env: NodeJS.ProcessEnv, const repository = env.GITHUB_REPOSITORY const source = z.object({ sha: z.string() }).parse(await api(`repos/${repository}/commits/${route.commit}`)) if (source.sha !== route.commit) throw new Error('desktop publish: source commit not found') - if (route.sourceRef === 'master') { - const comparison = z.object({ status: z.string() }).parse(await api(`repos/${repository}/compare/${route.commit}...master`)) - if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on master') + if (!/^dsh-v[0-9][0-9A-Za-z.-]*$/u.test(route.sourceRef)) { + const comparison = z.object({ status: z.string() }).parse(await api(`repos/${repository}/compare/${route.commit}...${encodeURIComponent(`refs/heads/${route.sourceRef}`)}`)) + if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on the selected repository branch') } else { - const tagged = z.object({ sha: z.string() }).parse(await api(`repos/${repository}/commits/${route.sourceRef}`)) + const tagged = z.object({ sha: z.string() }).parse(await api(`repos/${repository}/commits/${encodeURIComponent(`refs/tags/${route.sourceRef}`)}`)) if (tagged.sha !== route.commit) throw new Error('desktop publish: source tag changed') } const environment = z.object({ protection_rules: z.array(z.object({ type: z.string(), reviewers: z.array(z.unknown()).optional() })) }) diff --git a/apps/desktop/scripts/retain-desktop-ci.ts b/apps/desktop/scripts/retain-desktop-ci.ts index 280d043e08..69bade9712 100644 --- a/apps/desktop/scripts/retain-desktop-ci.ts +++ b/apps/desktop/scripts/retain-desktop-ci.ts @@ -6,6 +6,7 @@ import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDeskt import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts' import { validateDesktopBuildVersion } from './desktop-build-version.mjs' +import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' function required(env: NodeJS.ProcessEnv, name: string): string { const value = env[name] @@ -66,8 +67,10 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve( } const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name))) if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`) + const nextStep = desktopPublishQualificationError(qualification) + ?? `Publish separately with package_run_id=${env.GITHUB_RUN_ID} and package_run_attempt=${env.GITHUB_RUN_ATTEMPT}` if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, - `\nRetained package: ${env.GITHUB_RUN_ID}/${env.GITHUB_RUN_ATTEMPT}\n\nPublish separately with package_run_id=${env.GITHUB_RUN_ID} and package_run_attempt=${env.GITHUB_RUN_ATTEMPT}\n`) + `\nRetained package: ${env.GITHUB_RUN_ID}/${env.GITHUB_RUN_ATTEMPT}\n\n${nextStep}\n`) return retained } diff --git a/apps/desktop/scripts/run-windows-runner-task.ps1 b/apps/desktop/scripts/run-windows-runner-task.ps1 new file mode 100644 index 0000000000..1fe68ad15b --- /dev/null +++ b/apps/desktop/scripts/run-windows-runner-task.ps1 @@ -0,0 +1,31 @@ +# Repeating Task Scheduler triggers recover an exited launcher without relying on action exit-code retries. +$ErrorActionPreference = 'Stop' +$runnerRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)).TrimEnd('\','/') +$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($runnerRoot.ToUpperInvariant()))) +$taskName = 'DSH-Packaging-' + $digest.Substring(0,16) +$log = Join-Path $PSScriptRoot ('run-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '-' + $PID + '.log') +try { + @{ Pid = $PID; Started = (Get-Process -Id $PID).StartTime.ToUniversalTime().Ticks } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'process.json') + $config = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'config.json') -Raw | ConvertFrom-Json + $attemptFile = Join-Path $PSScriptRoot 'attempts.txt' + $attempts = [int](Get-Content -LiteralPath $attemptFile -Raw) + if ($attempts -lt 0 -or $config.RestartCount -lt 1 -or $config.RestartCount -gt 999) { throw 'Invalid restart budget; reinstall the task.' } + if ($attempts -gt $config.RestartCount) { + $null = Disable-ScheduledTask -TaskName $config.TaskName -TaskPath '\' + Set-Content -LiteralPath $log -Value 'Restart budget exhausted. Task disabled; inspect runner logs, fix the cause and run start.' + exit 1 + } + Set-Content -LiteralPath $attemptFile -Value ($attempts + 1) + & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'start.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log + $code = $LASTEXITCODE + Add-Content -LiteralPath $log -Value "Runner launcher exited with code $code." + # An unexpected exit is a task failure even when run.cmd hides the listener's nonzero status; the log retains its code. + exit 1 +} catch { + # A missing or unwritable counter cannot bound retries; the task name must not depend on readable configuration. + $null = Disable-ScheduledTask -TaskName $taskName -TaskPath '\' + $message = 'Runner task failed: ' + $_.Exception.Message + [Console]::Error.WriteLine($message) + Add-Content -LiteralPath $log -Value $message + exit 1 +} diff --git a/apps/desktop/scripts/runtime-patch-policy.ts b/apps/desktop/scripts/runtime-patch-policy.ts index d2ea174f2c..1bca41b947 100644 --- a/apps/desktop/scripts/runtime-patch-policy.ts +++ b/apps/desktop/scripts/runtime-patch-policy.ts @@ -8,7 +8,8 @@ export type DesktopRuntimePatch = /** 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.1.0': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' }, + '@botiverse/oar@0.67.0': { scope: 'workspace-only', reason: 'Patched OAR adapters are embedded in the provider bundle.' }, + '@earendil-works/pi-ai@1.3.0': { 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.' }, @@ -20,6 +21,6 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly> = { - // The provider declares ^1.1.0; its patch is qualified against 1.1.0. - '@earendil-works/pi-ai@^1.1.0': '1.1.0', + // The provider declares ^1.3.0; its patch is qualified against 1.3.0. + '@earendil-works/pi-ai@^1.3.0': '1.3.0', } diff --git a/apps/desktop/scripts/smoke-installer-directories.ps1 b/apps/desktop/scripts/smoke-installer-directories.ps1 index 092750b2c1..35bd4ba8cc 100644 --- a/apps/desktop/scripts/smoke-installer-directories.ps1 +++ b/apps/desktop/scripts/smoke-installer-directories.ps1 @@ -29,7 +29,7 @@ try { $brokenArchive = Join-Path $scratch 'broken.7z' [System.IO.File]::WriteAllText($brokenArchive, 'invalid archive') - foreach ($mode in @('first', 'upgrade', 'locked', 'missing-stage', 'broken', 'cancelled')) { + foreach ($mode in @('first', 'upgrade', 'transient-lock', 'locked', 'locked-stage', 'missing-stage', 'restore-conflict', 'broken', 'cancelled')) { $caseRoot = Join-Path $scratch $mode $target = Join-Path $caseRoot 'Application' New-Item -ItemType Directory -Path $caseRoot | Out-Null @@ -47,34 +47,83 @@ try { $compileArgs = @('/V2', "/DOUTPUT_FILE=$probe", "/DPAYLOAD_FILE=$caseArchive", "/DTARGET_DIR=$target", "/DDSH_SEVENZIP_PATH=$SevenZip") if ($FrameLibrary) { $compileArgs += @("/DSOURCE_DLL=$FrameLibrary", "/DREPORT_DIR=$reportDir") } if ($mode -eq 'missing-stage') { $compileArgs += '/DMISSING_STAGE' } + if ($mode -eq 'locked-stage') { $compileArgs += '/DLOCKED_STAGE' } + if ($mode -eq 'restore-conflict') { $compileArgs += '/DRESTORE_CONFLICT' } if ($mode -eq 'cancelled') { $compileArgs += '/DCANCELLED' } Invoke-Checked $Makensis ($compileArgs + $fixture) if ($null -ne $SignExecutable) { & $SignExecutable $probe } $handle = $null $process = $null try { - if ($mode -eq 'locked') { + if ($mode -in @('locked', 'transient-lock')) { $handle = [System.IO.File]::Open((Join-Path $target 'asset.txt'), 'Open', 'Read', 'Read') } $process = Start-Process -FilePath $probe -ArgumentList '/S' -WindowStyle Hidden -PassThru + if ($mode -eq 'transient-lock') { + $deadline = [Diagnostics.Stopwatch]::StartNew() + $observedFailure = $false + while (-not $process.HasExited -and $deadline.Elapsed.TotalSeconds -lt 30) { + $pluginPath = "$target.plugin-dir" + if (Test-Path -LiteralPath $pluginPath) { + $trace = Join-Path ([IO.File]::ReadAllText($pluginPath)) 'directory-operations.log' + if (Test-Path -LiteralPath $trace) { + $stream = [IO.File]::Open($trace, 'Open', 'Read', 'ReadWrite') + $reader = [IO.StreamReader]::new($stream) + try { $attempts = $reader.ReadToEnd() } finally { $reader.Dispose() } + if ($attempts -match 'Windows error: (5|32|33)\b') { + $observedFailure = $true + break + } + } + } + Start-Sleep -Milliseconds 20 + } + if (-not $observedFailure) { throw 'Transient-lock smoke did not observe the failed operation before release' } + $handle.Dispose() + $handle = $null + } if (-not $process.WaitForExit(60000)) { $process.Kill($true) $process.WaitForExit() throw "Directory smoke $mode did not exit within 60 seconds" } - $expectedExit = if ($mode -in @('first', 'upgrade')) { 0 } else { 2 } + $expectedExit = if ($mode -in @('first', 'upgrade', 'transient-lock')) { 0 } else { 2 } if ($process.ExitCode -ne $expectedExit) { throw "Directory smoke $mode exited with $($process.ExitCode), expected $expectedExit" } } finally { if ($null -ne $handle) { $handle.Dispose() } - if ($null -ne $process) { $process.Dispose() } + if ($null -ne $process) { + if (-not $process.HasExited) { $process.Kill($true); $process.WaitForExit() } + $process.Dispose() + } } - $expectedAsset = if ($mode -in @('first', 'upgrade')) { 'new asset' } else { 'old asset' } + if ($mode -eq 'restore-conflict') { + $backups = @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter 'Application.old-*') + if ($backups.Count -ne 1 -or [IO.File]::ReadAllText((Join-Path $backups[0].FullName 'asset.txt')) -ne 'old asset' -or + [IO.File]::ReadAllText((Join-Path $backups[0].FullName 'obsolete.txt')) -ne 'old only' -or + [IO.File]::ReadAllText((Join-Path "$target.conflict" 'unrelated.txt')) -ne 'unrelated file') { + throw 'Failed restoration did not preserve the backup and the conflicting destination' + } + if ((Test-Path -LiteralPath $target) -or @(Get-ChildItem -LiteralPath $caseRoot -Directory -Filter 'Application.new-*').Count -ne 0) { + throw 'Reported restoration failure retried recovery or retained its unlocked staging directory' + } + if ($FrameLibrary) { + $reports = @(Get-ChildItem -LiteralPath $reportDir -File) + if ($reports.Count -ne 1) { throw 'Failed restoration did not save exactly one report' } + $report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8 + foreach ($expected in @('promote: Windows error: 183', 'restore: Windows error: 183', 'Restoration error: 183', "Preserved backup: $($backups[0].FullName)")) { + if (-not $report.Contains($expected)) { throw "Failed restoration report lacks '$expected'" } + } + } + Write-Output "Directory replacement smoke passed: $mode" + continue + } + $expectedAsset = if ($mode -in @('first', 'upgrade', 'transient-lock')) { 'new asset' } else { 'old asset' } if ([System.IO.File]::ReadAllText((Join-Path $target 'asset.txt')) -ne $expectedAsset) { throw "Directory smoke $mode changed the wrong version" } - $expectObsolete = $mode -notin @('first', 'upgrade') + $expectObsolete = $mode -notin @('first', 'upgrade', 'transient-lock') if ((Test-Path -LiteralPath (Join-Path $target 'obsolete.txt')) -ne $expectObsolete) { throw "Directory smoke $mode did not preserve the expected old-only file state" } @@ -82,7 +131,6 @@ try { throw "Directory smoke $mode left transaction directories behind" } if ($FrameLibrary) { - # Only an extraction failure writes a report; it must quote 7-Zip's verdict so support can read the cause. $reports = @(if (Test-Path -LiteralPath $reportDir) { Get-ChildItem -LiteralPath $reportDir -File }) if ($mode -eq 'broken') { if ($reports.Count -ne 1 -or $reports[0].Name -notmatch '^extract-failure-20\d{6}-\d{6}\.log$') { @@ -92,6 +140,19 @@ try { foreach ($expected in @('Result: 7-Zip exit code 2 (fatal error)', "Archive: $caseArchive", 'Cannot open the file as [7z] archive')) { if (-not $report.Contains($expected)) { throw "Directory smoke $mode report lacks '$expected'" } } + } elseif ($mode -in @('locked', 'locked-stage', 'missing-stage')) { + if ($reports.Count -ne 1 -or $reports[0].Name -notmatch '^directory-failure-20\d{6}-\d{6}\.log$') { + throw "Directory smoke $mode did not save exactly one directory report" + } + $report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8 + $expectedError = if ($mode -ne 'missing-stage') { '(5|32|33)' } else { '2' } + if ($report -notmatch "Windows error: $expectedError\b") { throw "Directory smoke $mode reported an unexpected Windows error" } + foreach ($expected in @('Operation: MoveFileExW', 'Restoration error: 0')) { + if (-not $report.Contains($expected)) { throw "Directory smoke $mode report lacks '$expected'" } + } + $attempts = ([regex]::Matches($report, 'Windows error: ')).Count - 1 + $expectedAttempts = if ($mode -ne 'missing-stage') { 10 } else { 1 } + if ($attempts -ne $expectedAttempts) { throw "Directory smoke $mode recorded $attempts failed attempts, expected $expectedAttempts" } } elseif ($reports.Count -ne 0) { throw "Directory smoke $mode saved an unexpected extraction report" } diff --git a/apps/desktop/scripts/windows-directory-installer.mjs b/apps/desktop/scripts/windows-directory-installer.mjs index 0f2f985142..8508730b47 100644 --- a/apps/desktop/scripts/windows-directory-installer.mjs +++ b/apps/desktop/scripts/windows-directory-installer.mjs @@ -37,7 +37,7 @@ export function directoryInstallSection(source) { !macroend`) result = replaceOnce(result, '!insertmacro setLinkVars', `!insertmacro setLinkVars !insertmacro dshStageApplication`) - result = replaceOnce(result, '!insertmacro installApplicationFiles', 'Call dshPromoteDirectories\nIfErrors 0 +4\n SetErrorLevel 2\n MessageBox MB_OK|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDOK\n Quit') + result = replaceOnce(result, '!insertmacro installApplicationFiles', 'Call dshPromoteDirectories\nIfErrors 0 +3\n SetErrorLevel 2\n Quit') result = replaceOnce(result, '!ifdef UNINSTALLER_ICON\n File /oname=uninstallerIcon.ico "${UNINSTALLER_ICON}"\n!endif\n', '') // The staging macro uses the upstream installer macro, including its signed uninstaller. return result diff --git a/apps/desktop/scripts/windows-runner-task.ps1 b/apps/desktop/scripts/windows-runner-task.ps1 new file mode 100644 index 0000000000..65c6d08799 --- /dev/null +++ b/apps/desktop/scripts/windows-runner-task.ps1 @@ -0,0 +1,122 @@ +# Manage a registered packaging runner in the current user's interactive Windows session. +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][ValidateSet('install','start','stop','status','uninstall')][string]$Action, + [Parameter(Mandatory=$true)][string]$RunnerRoot, + [string]$ConfigRoot, + [ValidateRange(1,1440)][int]$RestartMinutes = 1, + [ValidateRange(1,999)][int]$RestartCount = 10 +) +$ErrorActionPreference = 'Stop' +if (-not $IsWindows) { throw 'This command requires PowerShell 7 on Windows.' } +if ($Action -ne 'install' -and @('ConfigRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) { + throw 'ConfigRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.' +} +if ($RunnerRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'RunnerRoot must be an absolute local directory.' } +$RunnerRoot = [IO.Path]::GetFullPath($RunnerRoot).TrimEnd('\','/') +if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot '.runner') -PathType Leaf)) { throw 'Register the runner before managing its task.' } +for ($ancestor = [IO.DirectoryInfo]::new($RunnerRoot); $null -ne $ancestor; $ancestor = $ancestor.Parent) { + if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'RunnerRoot cannot contain linked directories.' } +} +$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($RunnerRoot.ToUpperInvariant()))) +$taskName = 'DSH-Packaging-' + $digest.Substring(0,16) +$serviceDir = Join-Path $RunnerRoot 'dsh-task' +$task = Get-ScheduledTask -TaskPath '\' | Where-Object TaskName -eq $taskName +if ($task -and $task.Description -ne "DSH packaging runner: $RunnerRoot; owner: $sid") { throw 'Task ownership differs; inspect it in Task Scheduler.' } + +if ($Action -eq 'install') { + if ($task) { throw 'Task already installed; stop and uninstall before reinstalling.' } + if (-not $ConfigRoot -or $ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'Install requires an absolute ConfigRoot.' } + $ConfigRoot = [IO.Path]::GetFullPath($ConfigRoot) + $pwsh = Join-Path $PSHOME 'pwsh.exe' + & $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly + if ($LASTEXITCODE -ne 0) { throw 'Runner preflight failed; no task was installed.' } + if (Test-Path -LiteralPath $serviceDir) { + if ((Get-Item -LiteralPath $serviceDir).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Task directory cannot be a link.' } + } else { + $null = New-Item -ItemType Directory -Path $serviceDir + } + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1') + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1') + @{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8 + Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0' + $arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -File "' + (Join-Path $serviceDir 'run.ps1') + '"' + $execution = New-ScheduledTaskAction -Execute $pwsh -Argument $arguments -WorkingDirectory $RunnerRoot + $principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited + $trigger = @( + (New-ScheduledTaskTrigger -AtLogOn -User $sid), + (New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes($RestartMinutes) -RepetitionInterval (New-TimeSpan -Minutes $RestartMinutes)) + ) + $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $settings.Enabled = $false + $null = Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $execution -Principal $principal -Trigger $trigger -Settings $settings -Description "DSH packaging runner: $RunnerRoot; owner: $sid" + Write-Output "Installed disabled task $taskName. Run start while logged in; reboot requires desktop login. Logs: $serviceDir" + exit 0 +} +if (-not $task) { throw 'Task is not installed for this runner directory.' } +switch ($Action) { + 'status' { + $info = Get-ScheduledTaskInfo -InputObject $task + $launches = [int](Get-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Raw) + $config = Get-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Raw | ConvertFrom-Json + [pscustomobject]@{ TaskName = $taskName; State = $task.State; LastRunTime = $info.LastRunTime; LastTaskResult = $info.LastTaskResult; Launches = $launches; RemainingLaunches = [Math]::Max(0, 1 + $config.RestartCount - $launches); Logs = $serviceDir } + } + 'start' { + if ($task.State -eq 'Running') { Write-Output "$taskName is already running."; break } + Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0' + $null = Enable-ScheduledTask -InputObject $task + Start-ScheduledTask -InputObject $task + Write-Output "Start requested for $taskName. Verify Connected to GitHub and Listening for Jobs in $serviceDir." + } + 'stop' { + # Operators pause workflow dispatch first; this local check cannot reserve an idle GitHub runner. + $workers = Get-CimInstance Win32_Process -Filter "Name = 'Runner.Worker.exe'" + foreach ($worker in $workers) { + if (-not $worker.ExecutablePath -or $worker.ExecutablePath.StartsWith($RunnerRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw 'A runner job is active or its location is unreadable. Wait for completion before stopping.' + } + } + $null = Disable-ScheduledTask -InputObject $task + $task = Get-ScheduledTask -TaskName $taskName -TaskPath '\' + if ($task.State -eq 'Running') { + $recordFile = Join-Path $serviceDir 'process.json' + if (-not (Test-Path -LiteralPath $recordFile)) { throw 'Running task process identity is unavailable. Automatic starts are disabled; inspect the task before stopping it.' } + $record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json + $owner = Get-Process -Id $record.Pid -ErrorAction SilentlyContinue + if (-not $owner -or $owner.StartTime.ToUniversalTime().Ticks -ne $record.Started) { + throw 'Running task process identity is unavailable. Automatic starts are disabled; inspect the task before stopping it.' + } + # Parent IDs can be reused; this snapshot supplies wait handles, not termination authority. + $processes = @(Get-CimInstance Win32_Process) + $ownedIds = [Collections.Generic.HashSet[int]]::new() + $null = $ownedIds.Add($owner.Id) + do { + $added = $false + foreach ($process in $processes) { + if ($ownedIds.Contains([int]$process.ParentProcessId) -and $ownedIds.Add([int]$process.ProcessId)) { $added = $true } + } + } while ($added) + $owned = @(foreach ($ownedId in $ownedIds) { Get-Process -Id $ownedId -ErrorAction SilentlyContinue }) + & "$env:SystemRoot/System32/taskkill.exe" /PID $owner.Id /T /F + if ($LASTEXITCODE -ne 0 -and -not $owner.HasExited) { throw 'Runner task process tree could not be stopped; inspect it before restarting.' } + foreach ($process in $owned) { + if (-not $process.WaitForExit(30000)) { throw 'Runner task child remains alive; inspect it before restarting.' } + } + } + Stop-ScheduledTask -InputObject $task + $deadline = [DateTime]::UtcNow.AddSeconds(30) + do { + $state = (Get-ScheduledTask -TaskName $taskName -TaskPath '\').State + if ($state -ne 'Running') { break } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + if ($state -eq 'Running') { throw 'Task remains running; inspect its processes before further maintenance.' } + Write-Output "Stopped and disabled $taskName. Run start to enable automatic starts again." + } + 'uninstall' { + if ($task.State -ne 'Disabled') { throw 'Stop the task before uninstalling.' } + Unregister-ScheduledTask -InputObject $task -Confirm:$false + Write-Output "Uninstalled $taskName. Registration, configuration and logs remain in $RunnerRoot." + } +} diff --git a/apps/desktop/tests/desktop-package-selection.spec.ts b/apps/desktop/tests/desktop-package-selection.spec.ts index f341a918f4..fa6a042775 100644 --- a/apps/desktop/tests/desktop-package-selection.spec.ts +++ b/apps/desktop/tests/desktop-package-selection.spec.ts @@ -95,14 +95,24 @@ it('resolves an old locally retained package with publishRequested=false without await expect(resolveRetainedDesktopPublication(env, path => path.includes('/environments/') ? { protection_rules: [] } : api(path))) .rejects.toThrow(/reviewers/u) await expect(resolveRetainedDesktopPublication(env, path => path.includes('/compare/') ? { status: 'diverged' } : api(path))) - .rejects.toThrow(/not on master/u) + .rejects.toThrow(/not on the selected repository branch/u) const manifestPath = join(root, 'releases/42/2/manifest.json') const original = await readFile(manifestPath, 'utf8') const tagged = await verifyRetainedDesktopRelease(join(root, 'releases'), '42', '2') + tagged.source.sourceRef = 'feature/desktop-update' + await writeFile(manifestPath, JSON.stringify(tagged)) + expect(await resolveRetainedDesktopPublication(env, api)).toMatchObject({ sourceRef: 'feature/desktop-update', commit: source.commit }) + expect(apiCalls).toContain(`repos/owner/repo/compare/${source.commit}...refs%2Fheads%2Ffeature%2Fdesktop-update`) + await expect(resolveRetainedDesktopPublication(env, path => path.includes('/compare/') ? { status: 'diverged' } : api(path))) + .rejects.toThrow(/selected repository branch/u) + await expect(resolveRetainedDesktopPublication(env, (path) => { + if (path.includes('/compare/')) throw new Error('branch deleted or missing') + return api(path) + })).rejects.toThrow(/branch deleted/u) tagged.source.sourceRef = 'dsh-v1.0.0' await writeFile(manifestPath, JSON.stringify(tagged)) expect(await resolveRetainedDesktopPublication(env, api)).toMatchObject({ sourceRef: 'dsh-v1.0.0' }) - await expect(resolveRetainedDesktopPublication(env, path => path.endsWith('/commits/dsh-v1.0.0') ? { sha: 'c'.repeat(40) } : api(path))) + await expect(resolveRetainedDesktopPublication(env, path => path.endsWith('/commits/refs%2Ftags%2Fdsh-v1.0.0') ? { sha: 'c'.repeat(40) } : api(path))) .rejects.toThrow(/source tag changed/u) await writeFile(manifestPath, original) await writeFile(join(root, 'releases/42/2/installer.dmg'), 'changed') diff --git a/apps/desktop/tests/desktop-publication-failure.spec.ts b/apps/desktop/tests/desktop-publication-failure.spec.ts index f19e1f76b5..6f9e8d05e6 100644 --- a/apps/desktop/tests/desktop-publication-failure.spec.ts +++ b/apps/desktop/tests/desktop-publication-failure.spec.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { expect, it } from 'vitest' import { z } from 'zod' -import { desktopPublicationFailure } from '../scripts/desktop-publication-failure.ts' +import { DesktopCosOperationError, desktopPublicationFailure } from '../scripts/desktop-publication-failure.ts' import { DesktopPublishError } from '../scripts/desktop-publish.ts' it('preserves actionable validation and filesystem diagnostics', () => { @@ -34,3 +34,13 @@ it('prints rollback usage through the real CLI before any audit or remote operat expect(diagnostic.code).toBe('validation') expect(diagnostic.message).toContain('Usage: rollback-desktop-channel.ts') }) + +it('omits malformed provider receipt fields and never copies raw errors into COS diagnostics', () => { + const error = new DesktopCosOperationError('PutObject', { code: 'AccessDenied', statusCode: 'fixture-secret', + RequestId: 'Authorization: fixture-secret', body: 'fixture-secret', message: 'fixture-secret' }) + const failure = desktopPublicationFailure(error, 'publication') + expect(failure).toMatchObject({ code: 'AccessDenied', operation: 'PutObject' }) + expect(failure).not.toHaveProperty('requestId') + expect(failure).not.toHaveProperty('httpStatus') + expect(JSON.stringify(failure)).not.toContain('fixture-secret') +}) diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts index f4aaedfb8c..39e717a827 100644 --- a/apps/desktop/tests/desktop-publication-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' import { retainDesktopCi } from '../scripts/retain-desktop-ci.ts' +import { desktopPublicationSummary } from '../scripts/desktop-publication-summary.ts' import { prepareDesktopPublication } from '../scripts/publish-desktop-ci.ts' it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s build without credentials or source checkout manifests', async (target) => { @@ -19,7 +20,7 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu const releaseId = 'a'.repeat(32) await writeFile(join(configRoot, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${releaseId}\n`) await writeFile(join(configRoot, 'publishing/test.json'), JSON.stringify({ - bucket: 'fixture-1250000000', origin: 'https://example.com', credentialsFile: join(root, 'absent-credentials.json'), + bucket: 'fixture-1250000000', origin: 'https://EXAMPLE.com:443/', credentialsFile: join(root, 'absent-credentials.json'), })) const commit = 'b'.repeat(40) const base = `deepseek-harness-1.0.0-${target}` @@ -51,6 +52,14 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu expect(prepared.plan.version).toBe('1.0.0') expect(prepared.plan.artifacts.every(file => file.path.startsWith(retained))).toBe(true) expect(signatureChecks).toBe(1) + const summary = await desktopPublicationSummary(prepared.manifest.source, prepared.plan, 'c'.repeat(64)) + await expect(summary).toMatchFileSnapshot(`./expected/publication-${target}.md`) + const feed = prepared.plan.artifacts.find(file => file.channelMetadata)! + expect(summary).toContain(createHash('sha256').update(feed.contents!).digest('hex')) + expect(summary).toContain('https://github.com/owner/repo/actions/runs/10/attempts/2') + expect(summary).not.toContain('example.com//') + expect(summary).not.toContain(root) + expect(summary).not.toContain('absent-credentials') await expect(prepareDesktopPublication({ ...env, DESKTOP_APPROVED_PACKAGE: 'wrong' }, signature)).rejects.toThrow(/changed after approval/u) await expect(prepareDesktopPublication({ ...env, RUNNER_NAME: 'other-machine' }, signature)).rejects.toThrow(/different machine/u) await writeFile(join(retained, `${base}.${extension}`), 'changed') diff --git a/apps/desktop/tests/desktop-publication-summary.spec.ts b/apps/desktop/tests/desktop-publication-summary.spec.ts new file mode 100644 index 0000000000..5267d29034 --- /dev/null +++ b/apps/desktop/tests/desktop-publication-summary.spec.ts @@ -0,0 +1,46 @@ +import { createHash } from 'node:crypto' +import { expect, it } from 'vitest' +import { desktopPublicationSummary } from '../scripts/desktop-publication-summary.ts' +import type { DesktopRetainedSource } from '../scripts/desktop-release-store.ts' +import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' + +const source: DesktopRetainedSource = { + repository: 'owner/repo', runId: '20', attempt: '3', commit: 'a'.repeat(40), workflowCommit: 'b'.repeat(40), + sourceRef: 'dsh-v1.0.0', channel: 'latest', runnerName: 'windows-publisher', target: 'win-x64', + preset: 'production', productVersion: '1.0.0', version: '1.0.0', testReleaseId: null, qualification: 'signed', +} +const plan: DesktopUploadPlan = { + environment: 'production', target: 'win-x64', version: '1.0.0', bucket: 'production-1234567890', + publicUrl: 'https://download.example.com/dsh-desk/feeds/win-x64/', secretIdEnvName: 'SECRET_ID', secretKeyEnvName: 'SECRET_KEY', + artifacts: [ + { path: '/not-read/installer.exe', filename: 'deepseek-harness-1.0.0-win-x64.exe', key: 'dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe', + contents: 'installer bytes', contentType: 'application/octet-stream', channelMetadata: false }, + { path: '/not-read/latest.yml', filename: 'latest.yml', key: 'dsh-desk/feeds/win-x64/latest.yml', + contents: 'version: 1.0.0\n', contentType: 'application/yaml', channelMetadata: true }, + ], +} + +it('shows production destinations and tag after approval with upload-byte fingerprints', async () => { + const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64)) + await expect(summary).toMatchFileSnapshot('./expected/publication-production.md') + expect(summary).toContain('/latest/deepseek-harness-1.0.0-win-x64.exe') + expect(summary).toContain('https://download.example.com/dsh-desk/feeds/win-x64/latest.yml') + expect(summary).toContain(createHash('sha256').update('version: 1.0.0\n').digest('hex')) + expect(summary).not.toContain('测试 release ID |') + expect(summary).not.toContain('SECRET_KEY') + expect(summary).not.toContain('/not-read/') +}) + +it('escapes runner display text without adding Markdown rows or HTML elements', async () => { + const summary = await desktopPublicationSummary({ ...source, runnerName: 'runner|\n