Merge master and reconcile desktop diagnostic records

This commit is contained in:
winewill committed 2026-10-26 11:05:45 +08:00
commit 6cbbc8caa5
1215 files changed
+34067 -3952

No files matched your search

@@ -21,8 +21,8 @@
en: 985e0eb63f3d1fd6
zh: 337cc44fadc6227e
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts:
en: 9d885e8d58bc8bbb
zh: 41a9779fbdea8a1a
en: 9a4ba22142a7be24
zh: 8553b02a923641a3
/agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development:
en: b2a740a1ab7a5002
zh: a48be6ca6fc65912
@@ -48,7 +48,7 @@ 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 --compress Brotli` once per target → write `deepseek-harness-sdk-runtime-<platform>-<arch>` 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, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime-<platform>-<arch>` 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<repository-version>` 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.
@@ -48,7 +48,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
### 构建流水线与产物
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.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-<platform>-<arch>` 写入 `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`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `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<repository-version>` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。
@@ -27,14 +27,14 @@
en: d5d0d6a57a4fa581
zh: 265973b157f957d9
/agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller:
en: e338ac27490a380f
zh: 12600011ec742e5b
en: f432480a52414d56
zh: 15a3f175b69cdb53
/agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/activation-policy:
en: 534dcc62a26d305e
zh: 03d64abc0a46bbec
en: 03e24cdee8519a92
zh: 82dc02eb26d7d7f4
/agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/session-journal:
en: bb710f8cf35f2028
zh: aae2cfb8cd848069
en: 4de2965717900b96
zh: 1d129003824fa6bd
/agent-note-session-history-control-state-and-remote-event-transport/decision/session-controller/session-live-control:
en: a11d627cd68650c3
zh: 9aff565b9f712853
@@ -139,11 +139,11 @@ If a page request is canceled with its physical carrier generation, the journal
`packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace.
It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific.
It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, `events`, the deprecated `page`, `follow`, `control`, plus `observeEvent`, `projections`, `observeProjections`, `state`, `observeState`, and `wait`. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific.
The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner.
Other Host Remote namespaces reuse the same identity rules through `ctx.sessionController.inspect()` or `resolveAgent()`; they do not retain a second Session resolver.
Other Host Remote namespaces reuse the same resume-capable identity rules through `ctx.sessionController.resolveAgent()`; they do not retain a second Session resolver.
#### Activation policy
@@ -157,7 +157,8 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca
| `session.page(address)` | attached Session or persistence log | Never resumes an Agent |
| `session.follow(address)` | one live or prepared observation carrying the opening page and projections | Publishes the snapshot first, then promotes an ordinary cold Session once in the background |
| `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent |
| `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent |
| Fork source read | authorized durable Session data | A read does not resume an Agent |
| `session.attachment` | attachment store under the [image access rule](2026-10-23-store-scoped-image-reads.md) | A read does not resume an Agent |
| `session.updateQueue` | live Agent or ordinary persisted Session | Resumes an ordinary cold Session before mutating its Inbox |
| `session.cancel` | only the current live Agent | Does not resume vanished state |
| `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it |
@@ -173,9 +174,9 @@ Reading titles, lists, and projections does not require an Agent. An observation
#### Session journal
`session.page` returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit `throughSeq`; this value comes from the corresponding `session.follow` generation's opening cursor and fixes the read at the same log cut. A tail page without `beforeSeq` must end exactly at `throughSeq`, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages.
`session.events` returns a history window clipped on message boundaries with contiguous internal sequence numbers. `throughSeq` is optional: when omitted, the read selects the observed log end and returns it as the page's inclusive `cursor`; a supplied value fixes the read at that cut. A page without `beforeSeq` ends exactly at the selected cut, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. The deprecated `session.page` entry returns the same type.
The tail page also carries a projection baseline no later than `throughSeq`; older pages carry only historical entries. The Client merges pages and subsequent live control updates by projection watermark.
A page carries only its records, `hasMore`, and inclusive `cursor`; a projection baseline comes from a follow opening snapshot or the control stream, never from a page. The Client merges pages and subsequent live control updates by projection watermark.
Ordinary Sessions and direct subagents use one `SessionAddress` protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone.
@@ -139,11 +139,11 @@ repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼
`packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。
它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。
它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、`events`、已弃用的 `page`、`follow`、`control`,以及 `observeEvent`、`projections`、`observeProjections`、`state`、`observeState` 与 `wait`。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。
包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。
其他 Host Remote namespace 通过 `ctx.sessionController.inspect()` 或 `resolveAgent()` 复用同一身份规则,不保留第二份 Session resolver。
其他 Host Remote namespace 通过 `ctx.sessionController.resolveAgent()` 复用同一套允许恢复 Session 的身份规则,不保留第二份 Session resolver。
#### 激活策略
@@ -157,7 +157,8 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类
| `session.page(address)` | attached Session 或 persistence 日志 | 永不恢复 Agent |
| `session.follow(address)` | 一份携带 opening page 与 projection 的 live 或 prepared observation | 先发布 snapshot,再在后台把普通冷 Session 提升一次 |
| `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent |
| `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent |
| Fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent |
| `session.attachment` | 按[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)读取附件存储 | 读取不恢复 Agent |
| `session.updateQueue` | live Agent 或普通持久 Session | 修改 Inbox 前恢复普通冷 Session |
| `session.cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent |
| `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 |
@@ -173,9 +174,9 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类
#### Session 日志
`session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。
`session.events` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。`throughSeq` 可省略:省略时选取观察到的日志末尾,并把该切点作为页面的 inclusive `cursor` 返回;显式提供时把本次读取固定在该切点。无 `beforeSeq` 的 page 精确结束于所选切点,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。已弃用的 `session.page` 入口返回同一类型。
tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只携带历史 entries。Client 以 projection watermark 合并 page 与后续 live control 更新。
page 只携带记录、`hasMore` 和 inclusive `cursor`;projection baseline 来自 follow 的开场快照或 control 流,不来自 page。Client 以 projection watermark 合并 page 与后续 live control 更新。
普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。
@@ -51,8 +51,8 @@
en: 51f99cfca9c4c808
zh: ce95bab241d306f7
/agent-note-client-derived-presentation-from-raw-session-tool-events/host-design:
en: 16fd2b3e545ede21
zh: 4d28f52132495441
en: d5e5d1376dc42558
zh: c2f302b2a5d1de39
/agent-note-client-derived-presentation-from-raw-session-tool-events/host-design/performance-constraints:
en: 102b14e3eb9367ff
zh: d391099dbc41db24
@@ -195,9 +195,9 @@ The Client narrows `meta` locally at runtime. Renaming `presentationMeta` to mor
## Host Design
After obtaining source events, `SessionHistoryController.page()` performs only pagination and the existing projection-baseline calculation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set.
After obtaining source events, `HistoryController.history()` performs only pagination and requests projections only for a subagent address that needs descriptor validation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set.
`SessionHistoryController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events.
`WebController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events.
The controller has no `presenterScopeFor()`, `viewFor()`, `backscanArgs()`, `parseToolCall()`, or `jsonView()` path. Page state contains no presenter scope or argument resolver; follow state contains no `openCalls`, `fallbackEvents`, or presentation argument resolver. Each page/follow event is wrapped only as `{event}` while addressing, ownership, cursor, sequence, and projection logic remains intact.
@@ -195,9 +195,9 @@ Client 对 `meta` 做局部运行时收窄。是否把 `presentationMeta` 改名
## Host 端设计
`SessionHistoryController.page()` 在取得 source events 后只执行分页与现有 projection baseline 计算。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。
`HistoryController.history()` 在取得 source events 后只执行分页,并且只有需要校验 descriptor 的 subagent 地址才请求投影。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。
`SessionHistoryController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。
`WebController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。
Controller 不存在 `presenterScopeFor()`、`viewFor()`、`backscanArgs()`、`parseToolCall()` 或 `jsonView()` 路径。page state 不含 presenter scope 或参数 resolver;follow state 不含 `openCalls`、`fallbackEvents` 或 presentation 参数 resolver。每个 page/follow event 只包装成 `{event}`,地址、ownership、cursor、seq 与 projection 逻辑保持完整。
@@ -15,17 +15,17 @@
en: 480284ae1a6ce210
zh: d42c72ada9f019f8
/agent-note-session-observations-and-projection-owned-client-state/decision/observation-is-the-point-read-unit:
en: fac92f8547482a49
zh: 60240c47fbe4611f
en: 283136ae36512767
zh: e342b446c77ae300
/agent-note-session-observations-and-projection-owned-client-state/decision/source-resolution-and-lifetime:
en: e550ae9892474276
zh: fb6a2d9e91ebd4d9
en: bced4b90cb03525d
zh: b0c1ca4bf1064f89
/agent-note-session-observations-and-projection-owned-client-state/decision/projection-execution-boundary:
en: 148de86941b90f41
zh: c065a8e96e28b9d7
en: b3b235fa0250868a
zh: 0bb5eedf14cdafee
/agent-note-session-observations-and-projection-owned-client-state/decision/read-audiences:
en: 888f62659f4d868d
zh: e74491e8c85046ee
en: e394e9480340f69c
zh: 7eea994ee9fa380d
/agent-note-session-observations-and-projection-owned-client-state/decision/replayable-client-facts-are-projection-owned:
en: efded09f0cd30a8e
zh: 5e16580a39ff3359
@@ -48,11 +48,11 @@
en: 8d699dfc22ab5c5f
zh: 8dcd9fc6bd1594db
/agent-note-session-observations-and-projection-owned-client-state/verification:
en: df17217023668428
zh: 88f7da4deffc6bf9
en: 29f364d1031b7d85
zh: 2b193ceb52fb0406
/agent-note-session-observations-and-projection-owned-client-state/alternatives-considered:
en: a25138b4298cda16
zh: 92799a9bd8867d47
en: 90574351058d5d8c
zh: 6aa514cf47495ed2
/agent-note-session-observations-and-projection-owned-client-state/consequences:
en: e1f8bd8fa68de603
zh: 5afcd06f2611bc1e
@@ -24,7 +24,7 @@ The two ownership rules meet at the observation's projection snapshot. Lightweig
flowchart LR
List["list / search"] --> Corpus["SessionQuery corpus"]
Follow["follow"] --> Observe["observeSession"]
Page["page / attachment / fork"] --> Observe
Page["page / fork"] --> Observe
Subagent["subagent list / continuation"] --> Observe
Corpus --> Cache["projection cache hints"]
Cache --> ClientList["Client Session list"]
@@ -46,7 +46,7 @@ flowchart LR
`SessionQueryEngine.observeSession(sessionId, options)` returns a disposable `SessionObservation` containing one source kind, header, contiguous event prefix, cursor, optional projection snapshot, and the durable revision for a prepared source. An attached Session wins. Otherwise the reader's own prepared cache — keyed by `stat().revision` and pinned by observation leases — serves the cold Session, sharing one persistence read (`open(id, 'read')` + `read`) across concurrent observations, including an in-flight cold load.
Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` publish a snapshot and then transfer that exact prepared source to background Agent promotion without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold.
Every owner disposes its observation. `retain()` creates another lease over the same cut, which lets `session.follow` transfer that exact prepared source to background Agent promotion before delivering the snapshot, without rereading the log. A live Session that appears during cold resolution wins before publication; a disappeared live source is retried as cold.
### Source resolution and lifetime
@@ -56,7 +56,7 @@ Live preference is checked both before and after a cold borrow. The second check
Persistence absence maps to Session-not-found only after no attached Session exists. Durable corruption, source-identity conflict, cancellation, and operational persistence failure remain distinct `SessionQueryError` outcomes so API owners can preserve their own public error vocabulary without duplicating source detection.
The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer performed by the Session Controller after it has emitted the opening snapshot; other readers cannot turn an observation into a live Agent.
The observation owns no mutation authority. Its event array is an immutable prefix, and its prepared Session remains unpublished. Promotion is an explicit ownership transfer the Session Controller starts once the opening is computed, before delivering it; other readers cannot turn an observation into a live Agent.
Projection work is deliberately `all | none`. `all` computes every registered projection at the observation's event cursor; `none` leaves projection state untouched. There is no per-key preparation state, `projectionKeys` mode, or cached `viewedState`/`viewedValue` layer. A publisher may filter the completed values for an audience, but the underlying observation is never partly projected.
@@ -72,7 +72,7 @@ The registry owns fold state; each domain owns its `init`, `apply`, `view`, sche
Corpus listing remains a separate lightweight operation. `listSessions()` returns live-preferred headers without materializing every log. Session list first uses live projection state or durable projection-cache rows. Subagent discovery reads parent-catalog projections through exact observations. Session list may take one complete observation for an individually stored artifact within its configured small-log limit when cached metadata cannot establish whether it is blank; a large or unreadable cache miss remains visible with unknown hints.
`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` is reserved for older-history reads and gap repair. Observation-only reads never activate an Agent; only an ordinary follow may retain its prepared observation and request promotion after the opening snapshot has been delivered.
`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` supports current or fixed-cut history reads, older-history reads, and gap repair. Observation-only reads never activate an Agent; only an activation-enabled ordinary Follow may retain its prepared observation and request promotion once the opening snapshot is computed, before it is delivered. The [snapshot and subscription decision](2026-10-21-session-history-stream-and-snapshot.md) defines the Controller composition and passive subscription semantics.
### Read audiences
@@ -82,9 +82,10 @@ Each public operation chooses one query and projection policy. The choice is par
|---|---|---|---|
| `session.list` | Corpus headers, live state, and cached rows; bounded small-log fallback | Partial hints, or one full small-log observation | Never |
| `session.search` | Corpus authorization plus the configured search provider | None for result listing | Never |
| `session.follow` | One exact observation | All, carried in the opening snapshot | Ordinary cold Session only, after snapshot delivery |
| `session.follow` | One exact observation | All, carried in the opening snapshot | Only with `activate: true`, for an ordinary cold Session once the opening is computed, before delivery |
| `session.page` | One exact observation | None, except projection-backed subagent authorization | Never |
| Attachment and fork source | One exact observation | None unless authorization requires it | Never for the source |
| Fork source | One exact observation | None unless authorization requires it | Never for the source |
| Attachment | Direct store read under the [image access rule](2026-10-23-store-scoped-image-reads.md) | None | Never |
| Subagent list and continuation | Parent-catalog observations for listing; target observations for continuation | All; listing consumes catalog values, continuation consumes identity and inherited values | Never for listing; continuation follows its explicit command semantics |
### Replayable Client facts are projection-owned
@@ -171,7 +172,7 @@ These rules apply to new Session-derived Client state even when a direct event s
## Verification
Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion after snapshot delivery.
Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion once the opening is computed.
Client tests pin higher-sequence-wins projection storage, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin parent-catalog order, recursive traversal, branch diagnostics, cancellation, fork isolation, and no Agent activation during listing.
@@ -179,7 +180,7 @@ Client tests pin higher-sequence-wins projection storage, title updates, model c
**Keep source resolution in each consumer.** Rejected because every caller would continue to implement its own live race, persistence error mapping, preparation lifetime, cancellation, and projection cut, allowing both duplicate work and inconsistent results.
**Activate an Agent for every exact read.** Rejected because list, history, attachment, search, and subagent inspection are read operations. Activation loads plugins and changes process state, and it has no natural retirement point for pagination or catalog reads.
**Activate an Agent for every exact read.** Rejected because list, history, search, and subagent inspection are read operations. Activation loads plugins and changes process state, and it has no natural retirement point for pagination or catalog reads.
**Prepare only requested projection keys.** Rejected because a partially projected Session creates another lifecycle state that every cache, restore, plugin-registration, and caller path must track. Projection units are pure and few; computing all registered units for an exact observation is simpler than maintaining `O(E*k)` partial state instead of `O(E*P)` complete state.
@@ -24,7 +24,7 @@ Session 精确读取使用可保留的 `SessionObservation`,向 Client 暴露
flowchart LR
List["list / search"] --> Corpus["SessionQuery corpus"]
Follow["follow"] --> Observe["observeSession"]
Page["page / attachment / fork"] --> Observe
Page["page / fork"] --> Observe
Subagent["subagent list / continuation"] --> Observe
Corpus --> Cache["projection cache hints"]
Cache --> ClientList["Client Session list"]
@@ -46,7 +46,7 @@ flowchart LR
`SessionQueryEngine.observeSession(sessionId, options)` 返回可 dispose(资源释放)的 `SessionObservation`,其中包含同一份 source kind、header、连续事件前缀、cursor、可选 projection snapshot,以及 prepared source 的持久化 revision。已挂载 Session 优先;否则由读取方自己的 prepared cache——以 `stat().revision` 为键、由 observation lease 固定——提供冷 Session,让并发 observation 共享同一次持久化读取(`open(id, 'read')` + `read`),包括尚未完成的冷加载。
每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能够先发布 snapshot,再把完全相同的 prepared source 转交给后台 Agent promotion,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。
每个 owner 都会 dispose 自己的 observation。`retain()` 为同一切面创建另一份 lease,使 `session.follow` 能把完全相同的 prepared source 转交给后台 Agent promotion,并在交付 snapshot 前启动它,而无需重读日志。冷解析期间出现的 live Session 会在发布前胜出;已经消失的 live source 会按 cold source 重试。
### 数据源解析与生命周期
@@ -56,7 +56,7 @@ flowchart LR
只有在不存在已挂载 Session 后,persistence absence 才映射为 Session-not-found。持久数据损坏、source identity 冲突、取消和 persistence 操作失败分别保留不同的 `SessionQueryError`,API owner 因而可以维持自身公开错误词汇,而不用重复数据源判定。
Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening snapshot 发出后执行的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。
Observation 不拥有任何 mutation 权限。其事件数组是不可变前缀,prepared Session 保持未发布。Promotion 是 Session Controller 在 opening 计算完成后、交付前启动的显式 ownership transfer;其他读方不能把 observation 变成 live Agent。
Projection 工作明确只有 `all | none` 两种模式。`all` 在 observation 的事件 cursor 上计算所有已注册 projection;`none` 完全不触碰 projection 状态。系统不存在按 key preparation 的状态、`projectionKeys` 模式或额外的 `viewedState`/`viewedValue` cache。发布方可以按 audience 筛选已完成的值,但底层 observation 不会处于只算完部分 projection 的状态。
@@ -72,7 +72,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view`
语料库 list 仍是独立的轻量操作。`listSessions()` 返回 live-preferred header,而不物化每份日志。Session list 先读取 live projection 状态或持久 projection-cache row。Subagent 发现通过精确 observation 读取父目录 projection。当 cache 无法判断 Session 是否为空,且该 Session 拥有的独立产物未超过配置的小日志限制时,Session list 可以执行一次完整 observation;大型或不可读的 cache miss 仍以 hints 未知但 row 可见的方式返回。
`session.follow` 发布必需的 opening snapshot,其中包含 header、cursor、首个事件窗口和完整 projection baseline。重连使用另一份完整 snapshot 替换上一 generation。`session.page` 仅用于旧历史读取与 gap repair。只读 observation 不激活 Agent;只有普通 follow 可以保留 prepared observation,并在 opening snapshot 已交付后请求 promotion。
`session.follow` 发布必需的 opening snapshot,包含 header、cursor、初始事件窗口与完整 projection baseline。重连通过另一份完整快照替换之前的 generation。`session.page` 支持当前或固定切点的历史读取、旧历史分页及缺口修复。只读 Observation 不激活 Agent;只有显式启用激活的普通 Follow 可以在开场计算完成后、交付之前 retain prepared observation 并请求提升。[快照与订阅决策](2026-10-21-session-history-stream-and-snapshot.zh.md)定义 Controller 组合和纯订阅语义。
### 读取 audience
@@ -82,9 +82,10 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view`
|---|---|---|---|
| `session.list` | Corpus header、live state 和 cached row;有界小日志 fallback | 部分 hints,或一次完整小日志 observation | 从不 |
| `session.search` | Corpus 鉴权加已配置 search provider | 结果列表不计算 | 从不 |
| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅普通 cold Session,且在 snapshot 交付后 |
| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅 `activate: true` 的普通 cold Session,且在开场计算完成后、交付前 |
| `session.page` | 一份精确 observation | 不计算,但 projection-backed subagent 鉴权除外 | 从不 |
| Attachment 与 fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 |
| Fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 |
| Attachment | 按[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)直接读取存储 | 不计算 | 从不 |
| Subagent list 与 continuation | Listing 观察父目录;continuation 观察目标 | 全算;listing 消费目录值,continuation 消费身份与继承值 | Listing 从不;continuation 遵循显式命令语义 |
### 可回放的 Client 事实归 projection 所有
@@ -171,7 +172,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打
## 验证
Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及 snapshot 交付后的 promotion。
Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及开场计算完成后的 promotion。
Client 测试固定 higher-sequence-wins projection store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定父目录顺序、递归遍历、分支诊断、取消、fork 隔离,以及 listing 期间不激活 Agent。
@@ -179,7 +180,7 @@ Client 测试固定 higher-sequence-wins projection store、title 更新、model
**由各消费方继续解析数据源。** 否决,因为每个调用方都需要重复实现 live race、persistence error mapping、preparation lifetime、cancellation 和 projection cut,既会重复工作,也会产生不一致结果。
**每次精确读取都激活 Agent。** 否决,因为 list、history、attachment、search 与 subagent inspection 都是读取操作。Activation 会加载插件并改变进程状态,也没有适合分页或 catalog 读取的自然退出点。
**每次精确读取都激活 Agent。** 否决,因为 list、history、search 与 subagent inspection 都是读取操作。Activation 会加载插件并改变进程状态,也没有适合分页或 catalog 读取的自然退出点。
**只 prepare 被请求的 projection key。** 否决,因为只完成部分 projection 的 Session 会增加一种生命周期状态,所有 cache、restore、plugin registration 和调用路径都必须追踪它。Projection unit 数量少且为纯函数;精确 observation 计算全部已注册 unit,比为了 `O(E*k)` 而维护部分状态、取代 `O(E*P)` 完整状态更简单。
@@ -30,8 +30,8 @@
en: 9d2d50cb76754b74
zh: 9ceed3652475f18d
/agent-note-historical-session-reads-prepare-before-write-publication/decision/follow-and-agent-promotion:
en: 692c29db3e7ab07e
zh: 7da0cfdbc80f407d
en: 46da45b9b63e7298
zh: 3d55099479a80bd9
/agent-note-historical-session-reads-prepare-before-write-publication/problem-to-solution-mapping:
en: e067e187cd4d0a8c
zh: b0c79b635a637296
@@ -102,7 +102,7 @@ No external caller can append before the handle exists. `append`, `flush`, and `
### Follow and Agent promotion
`session.follow` opens history through the read path, restores the Session and projections, emits the opening snapshot, and then starts Agent promotion. Agent resume uses write open, so it waits for publication before the Agent accepts a new turn. History visibility and write readiness are separate timing points without introducing an unpublished append state.
`session.follow` opens history through the read path, restores the Session and projections, starts Agent promotion from the separately retained Observation when the caller requests activation, and then delivers the opening snapshot. Agent resume uses write open, so it waits for publication before the Agent accepts a new turn. History visibility and write readiness are separate timing points without introducing an unpublished append state.
## Problem-to-solution mapping
@@ -102,7 +102,7 @@ Handle 返回前,外部 caller 无法 append。因此 `append`、`flush` 与 `
### Follow 与 Agent promotion
`session.follow` 通过 read path 打开历史、恢复 Session 与 projections、发出 opening snapshot,然后启动 Agent promotion。Agent resume 使用 write open,因此会在 Agent 接收新一轮对话前等待 publication。历史可见与写入就绪成为两个明确时间点,同时不引入 unpublished append state。
`session.follow` 通过 read path 打开历史、恢复 Session 与 projections,在调用方请求激活时先用单独 retain 的 Observation 启动 Agent promotion,再交付 opening snapshot。Agent resume 使用 write open,因此会在 Agent 接收新一轮对话前等待 publication。历史可见与写入就绪成为两个明确时间点,同时不引入 unpublished append state。
## 问题与方案对照
@@ -39,8 +39,8 @@
en: ef015f605f7b00ab
zh: eee9c79faebf3dff
/agent-note-workspace-file-service/alternatives-considered:
en: 8eee658526db4465
zh: 4a655accf6a5c08a
en: c750d88e0e746a99
zh: 9e904ad4172fda49
/agent-note-workspace-file-service/consequences:
en: 246bb7aed62f6c9b
zh: debf6d4129ee477b
@@ -117,7 +117,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
**Serving navigable workspace documents.** Rejected on origin grounds; the [binary transfer](2026-09-17-workspace-file-binary-transfer.md) returns multipart data while HTML remains isolated in a Blob iframe.
**Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations.
**Log-reachable or workspace-contained authorization for file reads.** Requiring a Session-log reference excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations. Image attachment reads separately follow the [store-level access rule](2026-10-23-store-scoped-image-reads.md).
**Whole-file read and slice for the byte window.** The interim form of `readBytes` read the file from its start to the window's end through `readBytes(target, signal, offset + length)` and sliced. It cannot read a window of a file longer than that end — the seam refuses such a file as too large — so no window could ever report `eof: false`, which contradicts the reason the method exists. Rejected in favour of the `readByteRange` seam, whose bound is the window.
@@ -117,7 +117,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
**提供可导航的工作区文档。** 因源隔离问题不采用;[二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)返回 multipart 数据,HTML 仍隔离在 Blob iframe 中。
**文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制。
**文件读取采用“日志可达”或工作区包含授权。** 要求 Session 日志引用会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)以后端读取决策为准,只对工作区形态的操作保留包含限制。图片附件读取另行遵循[存储范围的访问规则](2026-10-23-store-scoped-image-reads.zh.md)。
**为字节窗口整文件读取再切片。** `readBytes` 的临时形态经 `readBytes(target, signal, offset + length)` 从文件开头读到窗口末端再切片。它读不了比该末端更长的文件的窗口——seam 会以过大拒绝这样的文件——因此没有任何窗口能报告 `eof: false`,与该方法存在的理由相悖。被否,改为以窗口为界的 `readByteRange` seam。
@@ -15,5 +15,5 @@
en: b78785f2a603eba8
zh: b8c268228da7e2e7
/agent-note-active-tool-headers-and-stateful-request-projection/consequences:
en: 93c32b795b71a1cc
zh: d42b5c563341c898
en: 60d7bf28fa5a8fd1
zh: a17b222e7aca01e2
@@ -30,7 +30,7 @@ DeepSeek translates projected updates into system-role `tool_addition` and `tool
## Consequences
Tool lists and logical update events are capability-independent; only outgoing declarations and messages vary. Calls to removed retained tools fail through the existing `UNKNOWN_TOOL` path. Changed definitions and incomplete auxiliary prefixes sacrifice prefix reuse for coherent declarations; unchanged restoration keeps the cached prefix on `in-history` routes only while the request series continues. Routes supporting tool updates and in-history system prompts preserve earlier messages when tools are added alongside a changed prompt or after an earlier prompt update. Routes without tool-update support still consolidate on tool changes; explicit series starts and surface replacements still consolidate on every route. No persisted fields or Session format revision are added.
Tool lists and logical update events are capability-independent; only outgoing declarations and messages vary. Calls to removed retained tools fail through the existing `UNKNOWN_TOOL` path. Changed definitions and incomplete auxiliary prefixes sacrifice prefix reuse for coherent declarations; unchanged restoration keeps the cached prefix on `in-history` routes only while the request series continues. Routes supporting tool updates and in-history system prompts preserve earlier messages when tools are added alongside a changed prompt or after an earlier prompt update. Routes without tool-update support still start a new series on tool changes; explicit series starts and surface replacements consolidate the prompt only when the effective text differs, and otherwise leave the surviving prompt node in place. No persisted fields or Session format revision are added.
The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations.
@@ -30,7 +30,7 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to
## Consequences
工具列表和逻辑更新事件不依赖能力;只有发出的声明和消息不同。调用已移除但保留声明的工具时,通过现有 `UNKNOWN_TOOL` 路径失败。定义变化和不完整辅助前缀为保证声明一致而放弃前缀复用;仅当请求序列继续时,未变化的恢复才在 `in-history` 路由上保留缓存前缀。同时支持工具更新和历史内系统提示词的路由,在工具添加与提示词变化同时发生或晚于此前提示词更新时,保留此前消息。不支持工具更新的路由仍在工具变化时归并提示词;所有路由仍在显式新序列和 surface 替换时归并。不增加持久字段或 Session 格式版本。
工具列表和逻辑更新事件不依赖能力;只有发出的声明和消息不同。调用已移除但保留声明的工具时,通过现有 `UNKNOWN_TOOL` 路径失败。定义变化和不完整辅助前缀为保证声明一致而放弃前缀复用;仅当请求序列继续时,未变化的恢复才在 `in-history` 路由上保留缓存前缀。同时支持工具更新和历史内系统提示词的路由,在工具添加与提示词变化同时发生或晚于此前提示词更新时,保留此前消息。不支持工具更新的路由仍在工具变化时开启新序列;显式新序列和 surface 替换只在有效文本不同时归并提示词,否则保留存活的提示词节点。不增加持久字段或 Session 格式版本。
手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。
@@ -9,11 +9,11 @@
en: abd0263d14861926
zh: e1736cd08e87f795
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision:
en: 52b45f1ac62e65ab
zh: 7f83cd4fb8dd09a6
en: fdd39b7e8fa89d70
zh: 4a0dfc81509e5485
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered:
en: 1737484c099fab52
zh: 303b144c52d167bd
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences:
en: 6ec74e7ac065bd5e
zh: 04d029059dea732b
en: 79d8d66071453d1f
zh: f940f0b6d74148d6
@@ -20,6 +20,8 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d
`Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen, no coercion hook is overridden, and a span is bounded by the exactly representable millisecond range, which is what makes the split exact. The published surface is `fromMilliseconds` and a whole-part `decompose()`, the two entries the migrated consumer calls.
The last copy of the canonical instant profile moves too. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` validated a stored schedule instant with its own regular expression and round trip; the shared `fourDigitYearEpoch` owns that same profile, so the migration now asks it and keeps its own error type and message. Because that migration reads released logs, its accepted set is a compatibility promise: `packages/session/session-format-v0-to-v1/tests/validation.spec.ts` accepts the four-digit-year bounds and refuses each spelling a regular expression alone would admit (`0000`, `2026-02-30`, a four-digit fraction, a numeric offset, surrounding whitespace).
The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s own description and README drop the `zero-dependency` phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why `packages/README.md` and the `util/` line in `AGENTS.md` keep the phrase while `packages/util/README.md` and this package's own description name what it loads.
Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included.
@@ -48,4 +50,4 @@ A value type publishes what its consumers call, so the span exposes the construc
The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, measured over more than eight thousand inputs including negatives, fractions, and values past a year. That measurement is not a committed test: the consumer's own suite pins the readings it renders (`1d 1h 1m 1s`, `1m 1s`, `4m 2s`, and the clamped zero). Two samples do change: `NaN` rendered `NaNs` and `+Infinity` rendered `Infinityd NaNs`, and both now throw from the span constructor, so a reading that is not a finite number fails where it is produced instead of rendering text. A `-Infinity` sample is unchanged, because the consumer clamps it to zero before the span is built.
Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own UTC-offset reading, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` also validates the canonical four-digit-year UTC instant with its own regular expression and round trip, which is a remaining copy of the instant profile this change did not touch.
Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own offset reading for display, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. Five time sites keep their own code, each for a different reason. `packages/experimental/tool-session-query/src/input.ts` parses a wider profile than the shared entry — seconds optional, any number of fractional digits, and the digits below the millisecond kept so two instants can be compared exactly — so substituting the shared parser would narrow what it accepts, and moving that profile into the library is a design step of its own. The `Date.parse(record.scheduledAt)` reads in `dsh-schedule` parse a value the write path already validated, so a discriminated decode would add a rejection branch that cannot occur in practice and change what a corrupt record reports. The `everySeconds`, `afterSeconds`, and `refreshMarginSeconds` multiplications each sit beside a validation that raises the package's own error, so routing them through a constructor that throws its own type would change the failure a caller sees. The UTC offset a client reports is computed in `packages/client/ui-settings-account/src/client/client-metadata.ts` instead of shared: the client bundle admits a cross-plugin value import only for the packages it lists as inline-safe, `dsh-util-time` is not one of them, and it imports the `Temporal` polyfill at module scope, so inlining it would ship that polyfill to the browser. The Desktop host computes the same offset from the shared library, where no such rule applies. `dsh-workspace` writes its record timestamps with the platform's `toISOString`, which is the canonical rendering for every representable epoch: the extended-year and fractional-truncation cases a shared writer would refuse are unreachable from `Date.now()` and from a `SessionHeader` epoch, so adopting the shared formatter there would add a module and a dependency without changing a byte.
@@ -20,6 +20,8 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结,不覆盖任何强制转换钩子,且时间段受可精确表示的毫秒范围约束——这正是拆分精确的前提。公开面就是 `fromMilliseconds` 与整数量拆解 `decompose()`,即已迁移消费方所调用的两个入口。
最后一份规范时刻剖面副本也一并迁移。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 原本用自己的一段正则与往返比对校验持久记录里的调度时刻;共享的 `fourDigitYearEpoch` 拥有同一剖面,因此该迁移改为询问它,并保留自己的错误类型与文案。由于这个迁移要读取已发布的日志,其接受集是兼容性承诺:`packages/session/session-format-v0-to-v1/tests/validation.spec.ts` 接受四位年份两端,并拒绝只靠正则会放进来的每种拼写(`0000`、`2026-02-30`、四位小数、数字偏移、首尾空白)。
受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此 `packages/README.md` 与 `AGENTS.md` 里的 `util/` 一行保留该说法,而 `packages/util/README.md` 与本包自己的描述写明它加载了什么。
可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。
@@ -48,4 +50,4 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分测得。该测量不是提交进仓库的测试:消费方自己那套测试固定了它渲染出的读数(`1d 1h 1m 1s`、`1m 1s`、`4m 2s`,以及被钳到零的那次)。有两个样本会变:`NaN` 原本渲染出 `NaNs`、`+Infinity` 原本渲染出 `Infinityd NaNs`,现在两者都由时间段构造函数抛出,于是非有限的读数在产生它的地方失败,而不是渲染出一段文本。`-Infinity` 样本不变,因为消费方在构造时间段之前已把它钳到零。
本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。
本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与用于展示的偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。有五处时间代码各自保留实现,原因各不相同。`packages/experimental/tool-session-query/src/input.ts` 解析的剖面比共享入口更宽——秒可省略、小数位不限、并保留下毫秒数字以便精确比较两个时刻——因此换成共享解析器会收窄它接受的输入,而把那条剖面搬进本库是另一项独立的设计。`dsh-schedule` 里的 `Date.parse(record.scheduledAt)` 读的是写入路径已经校验过的值,换成带判别值的解码会在每个读取点新增一个实际不可能发生的拒绝分支,并改变损坏记录的报告方式。`everySeconds`、`afterSeconds` 与 `refreshMarginSeconds` 三处乘法旁边都紧跟一条抛本包自有错误的校验,因此把它们改走会抛自身错误类型的构造函数,会改变调用方看到的失败。客户端上报的 UTC 偏移由 `packages/client/ui-settings-account/src/client/client-metadata.ts` 自行计算而不共享:客户端 bundle 只允许对列入 inline-safe 的包做跨插件值导入,`dsh-util-time` 不在其中,而且它在模块顶层静态导入 `Temporal` polyfill,内联它等于把该 polyfill 打进浏览器;Desktop 的宿主侧则用共享库计算同一个偏移,那里没有这条限制。`dsh-workspace` 用平台的 `toISOString` 写记录时间戳,对每个可表示的纪元那已是规范渲染:共享写入器会拒绝的扩展年份与小数截断两种情形,从 `Date.now()` 与 `SessionHeader` 的纪元出发都不可达,因此在那边采用共享格式化器只会增加一个模块与一条依赖,却不会改变任何字节。
@@ -9,11 +9,11 @@
en: b32a7ce6483b9b48
zh: ec0938ad032b99d8
/agent-note-application-consent-for-native-computer-use/decision:
en: 36c560c24c376f12
zh: 5e4ff72f66574e38
en: 28ec4cc587ba487b
zh: 8b89bbdff8eff6eb
/agent-note-application-consent-for-native-computer-use/alternatives-considered:
en: 80cd5fd93a42d937
zh: 2a0e241b1b8c2e1d
en: 9f41ecc5e34efe17
zh: b30ba6dae25b20b2
/agent-note-application-consent-for-native-computer-use/consequences:
en: 4bda3366af65250a
zh: 7c83561b4db07284
@@ -10,7 +10,7 @@ macOS privileges authorize an execution host, not a user's choice of target appl
## Decision
The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and the provider's existing profile configuration for saved grants. The existing question UI presents the offered labels; the executor rejects custom text or answers that do not select exactly one offered label. Permission-page icons and names are presentation metadata, excluded from saved identities and model results. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority.
The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and profile-scoped domain storage for saved grants. Its fixed approval presentation reuses the tool-approval card without adopting the session's tool-approval policy. Native icons and names are presentation metadata, excluded from saved identities and model results. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority.
System-permission buttons call the native helper on the DSH host and remain available to remote browsers. The [usage documentation](../../../../packages/experimental/cua/README.md#use-this-package) explains where to complete authorization; the page has no persistent remote-access notice.
@@ -18,9 +18,7 @@ A saved identity combines the canonical executable path with a verified designat
Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Signed selections retain the normal designated-requirement identity; an unverifiable executable uses a content identity for this pending selection and cannot create a saved grant. Preparing metadata before asking avoids starting an application the user refuses.
Session decisions are kept in memory by Session ID and application identity. Agent disposal and helper replacement release native handles without discarding these decisions; provider disposal or DSH restart clears them. This scope covers follow-up turns without relying on the model to decide when a user intent has ended.
Selections of the same application within one session are serialized, so a pending question does not produce competing decisions. Saved-grant additions and revocations share one mutation queue and read the current grant list inside it. Provider shutdown cancels writes still waiting for HMR admission; admitted writes settle before disposal completes. Refusals prevent another prompt until the user permits asking again; a restored saved grant takes precedence in both dispatch and the permission-page projection.
Conversation decisions survive provider disposal and DSH restart according to [profile consent storage](2026-10-19-cua-profile-consent-storage.md). That decision owns persistence, migration and cross-process revocation. The executor continues to serialize application selections within a conversation, refuse repeated prompts after denial, and give an explicit long-term grant precedence over conversation refusals.
Revocation invalidates the Agent connections that retain the application's handles, then awaits native cleanup. Reauthorization uses a fresh connection, so old app and window snapshots cannot become valid again. The [native object decision](2026-09-17-experimental-cua-object-api.md) continues to own process identity, coordinate semantics, and separation from the evaluator.
@@ -30,7 +28,7 @@ Revocation invalidates the Agent connections that retain the application's handl
**Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; session access supports unsigned development applications without that promise.
**Add an approval database or Session control stream.** Profile settings already own user configuration, and User Questions already owns interactive answers. The permission UI observes the provider and its owning settings namespace without another authorization store or event channel.
**Add an approval database or Session control stream.** User Questions already owns interactive answers. The existing domain service provides durable state without introducing a dedicated database or Session control stream; storage tradeoffs are owned by the profile consent decision.
**Disable permission buttons based on the browser address.** A user may operate the DSH host through screen sharing and complete authorization there. Port forwarding and proxies make the address an unreliable indication of whether the browser and DSH run on the same computer.
@@ -10,7 +10,7 @@ macOS 权限授权执行宿主,不能表达用户允许操作哪些目标应
## 决策
CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在 provider 的现有 profile 配置中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。现有问答界面展示授权选项;执行器拒绝自由文本或未准确选中一个所给选项的回答。权限页图标和名称仅用于展示,不进入保存的身份信息或模型结果。
CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在按 profile 隔离的存储域中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。 固定审批呈现复用工具审批卡片,同时保留独立于会话工具审批策略的授权规则。本机图标和名称仅用于展示,不进入保存的身份信息或模型结果。
系统权限按钮调用运行 DSH 的电脑上的原生 helper,远程访问时仍可使用。授权地点写在[使用说明](../../../../packages/experimental/cua/README.zh.md#use-this-package)中,页面不常驻远程访问提示。
@@ -18,9 +18,7 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。
选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。已签名应用沿用签名指定要求身份;签名不可验证的程序使用本次待启动选择的内容身份,不能生成长期许可。先准备元数据再询问,可以避免启动用户拒绝的应用。
会话决定按 Session ID 和应用身份保存在内存中。Agent 销毁或 helper 重建会释放原生句柄,但保留授权决定;提供方卸载或 DSH 重启后清除。会话范围可以覆盖用户的后续指令,无须由模型判断一次用户意图何时结束。
同一会话对同一应用的选择串行执行,避免未完成的问题产生相互冲突的决定。长期许可的新增和撤销共用写入队列,并在队列内读取当前许可列表。提供方关闭时取消尚在等待 HMR 调度的写入,已开始的写入会在销毁完成前结束。拒绝会阻止再次询问,直到用户允许重询;重新添加的长期许可在执行检查和权限页中均优先于旧拒绝记录。
会话决定按[profile 授权存储](2026-10-19-cua-profile-consent-storage.zh.md)的规则,在提供方卸载和 DSH 重启后保留。该记录负责持久化、迁移和跨进程撤销。执行器继续将同一对话对同一应用的选择串行化,拒绝后不再次询问,显式长期许可优先于会话拒绝。
撤销使保留目标应用句柄的 Agent 连接失效,并等待原生清理完成。重新授权使用新连接,因此旧应用对象和窗口快照不能重新生效。[原生对象决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责进程身份、坐标语义和与执行器分离的设计。
@@ -30,7 +28,7 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。
**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;会话授权支持未签名的开发应用,但不承诺跨进程复用。
**新增审批数据库或 Session 控制流。** Profile settings 已负责用户配置,User Questions 已负责交互回答。权限界面查询 provider,并监听其配置 namespace,无须另一份授权存储或事件通道。
**增加审批数据库或 Session 控制流。** User Questions 已经负责交互式回答。现有域服务提供持久状态,无须增加专用数据库或 Session 控制流;存储取舍由 profile 授权存储决策负责。
**根据浏览器访问地址禁用权限按钮。** 用户可能通过屏幕共享操作运行 DSH 的电脑,并在该电脑上完成授权。端口转发和代理使访问地址无法可靠判断浏览器与 DSH 是否运行在同一台电脑上。
@@ -27,17 +27,17 @@
en: a01ac57fc71a84f0
zh: 3e488ef3da5e4b8c
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control:
en: a55516339b0d49dd
zh: 3281df9ea7878fc0
en: 725a2d4145c0a75e
zh: b98a64532cadeaba
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/correspondence-with-rbac:
en: a78a2410eb52f777
zh: a868bf1b0e690c9f
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry:
en: 625b20552962e268
zh: 1136c0fd7cdc6901
en: d50ae22978165c57
zh: 8c1fcfb7dc2d3367
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources:
en: ec1677d8e338e115
zh: 6bedd163f208eaa9
en: 582f5f527ecc58c8
zh: 4a4b68361de2dcf0
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access:
en: e57f396239ea9f56
zh: ddb26278b1521ceb
@@ -45,8 +45,8 @@
en: 3c911f57f0f6ca4b
zh: 1509781eeda4c922
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order:
en: 6c58dc8777e107ef
zh: bc14605c96e4d8f3
en: 2d97a251cc4c9db0
zh: df072d5a96a9e82f
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events:
en: 5fe34ee5ec31942a
zh: ef39b020b0539b8f
@@ -75,14 +75,14 @@
en: 305ec45a7cd85ed7
zh: d2cd4209a56b1d0c
/agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered:
en: 130df2981ba550df
zh: e9a0a89af6fb50a3
en: 60da38bca6e9c3d6
zh: 61ea578fe8185b61
/agent-note-peer-principals-and-method-level-remote-access-control/consequences:
en: 89452c3c3f042e75
zh: 9bcad00a60c65303
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification:
en: 2279a8c153cc5b89
zh: 63c0b6462722f0a3
en: d7d0909974609247
zh: 6ebf20714f8d545b
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
en: 1e254cec680b1a37
zh: 06a31e057ba965aa
@@ -131,7 +131,7 @@ The single read point is `this.ctx.invocation.peer.principal`; an owner test is
### Method-level access control
Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule.
Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field or resolver, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule.
#### Correspondence with RBAC
@@ -165,7 +165,7 @@ export interface TypertResourceMap {}
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
// Runtime registries in typert
ctx.typert.resources.register(kind, { field }): TypertDisposer
ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
@@ -213,11 +213,11 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
| Rule | Content |
|---|---|
| Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution |
| Syntax | `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })` when a method must name its resource by hand; `session/page` and `session/follow` instead rely on the registered `session` default resolver; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution |
| Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource<SessionId>` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource<WorkspaceId>` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws |
| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` |
| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value |
| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point |
| Default extraction declarations | The owning domain registers either `{ field }` or `{ resolve }`, never both. SessionController registers `resolveSessionResource`; WorkspaceController registers `workspaceId`. Duplicate kinds are rejected. Lookup and Host Context resource declarations remain independent of these defaults |
| Resolution precedence | Per kind: explicit method reader, then lookup and Host Context identities, then the registered default field or resolver. A field reads the named top-level parameter or the first object argument. A resolver receives validated named `args` and ordered parameter descriptions once before lookup resolution. Defaults return one non-empty string or undefined; arrays are invalid |
| Argument values | Compiled codecs strip undeclared object fields; SRC fallback checks JSON safety without stripping them. Resolution and dispatch use the same validated values. The Session resolver rejects a request object containing both sessionId and address, even when equal. Lookup and Context identities remain wire values until after authorization |
| Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added |
| Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) |
| Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` |
@@ -228,7 +228,7 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
#### Endpoint resources
The table lists the endpoints #3854 viewers use and where their resources come from. Only `session/page` and `session/follow` need explicit `resources`; the rest rely on a lookup's `resource` or the default field:
The table lists the endpoints #3854 viewers use and where their resources come from. Session history endpoints use the registered Session resolver; special method readers can override defaults:
| Endpoint | Resource | Resolution source | Filtering inside the method |
|---|---|---|---|
@@ -241,16 +241,18 @@ The table lists the endpoints #3854 viewers use and where their resources come f
| `llm/listProviders` | `global` | no resource | no |
| `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no |
| `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no |
| `session/attachment` | session: `args.request.sessionId` | default field | no |
| `skills/list` | session: `args.request.sessionId` | default field | no |
| `session/fork` | session: `args.request.sessionId` | default field | no |
| `session/prompt` | session: `args.request.sessionId` | default field | no |
| `session/cancel` | session: `args.request.sessionId` | default field | no |
| `session/projections` | session: `args.request.sessionId` | default field | no |
| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | `resources` | no |
| `session/follow` | same as above | `resources` | no |
| `session/attachment` | session: `args.request.sessionId` | default resolver | no: a grant on one Session permits reads from the whole attachment store |
| `skills/list` | session: `args.request.sessionId` | default resolver | no |
| `session/fork` | session: `args.request.sessionId` | default resolver | no |
| `session/prompt` | session: `args.request.sessionId` | default resolver | no |
| `session/cancel` | session: `args.request.sessionId` | default resolver | no |
| `session/projections` | session: `args.request.sessionId` | default resolver | no |
| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | default resolver | no |
| `session/follow` | same as above | default resolver | no |
| `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no |
For `session/attachment`, `sessionId` identifies the resource for the Gateway check; it does not constrain which image may be read. Once that check permits the call, an image may belong to another Session or no Session, and the handler does not check whether the supplied Session exists. Policy authors must treat this action as access to the whole attachment store, as defined by the [image access rule](2026-10-23-store-scoped-image-reads.md).
A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource.
Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent `prompt` are `parentSessionId` / `childSessionId`, so automatic resolution finds no Session and resolves it to `global`; it must therefore stay out of every `global` role. `session/search` filters its hits per Session inside the method. `subagents/list` is not a Remote endpoint (`SubagentRuntime.list()` carries no `@Remote`).
@@ -392,7 +394,7 @@ Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer
| 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` |
| 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`), then validate the JSON arguments against their Typert-compiled types, decoding each once | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid`; a JSON argument failing validation: `gateway/input-invalid` (for owners and non-owners alike, before the access check) |
| 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — |
| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` |
| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then the registered default field or resolver; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` |
| 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors |
The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`.
@@ -600,7 +602,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
**Refusal reasons from conditions only.** Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; `explain` gives a reason only after the decision is already a refusal and never changes it.
**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field.
**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field or resolver.
**Dot-path resource declarations (`'request.address.sessionId'`).** Types cannot constrain a path; the parameter types of `resources` readers are inferred from the decorated method, so a typo fails compilation.
@@ -635,8 +637,8 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
| Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec |
| Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec |
| Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec |
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
| Resource resolution | A default field or resolver yields one resource; a default resolver returning an array is invalid; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, with undeclared fields stripped only by compiled codecs, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
| Resource registry | `typert.resources` registers one default field or resolver per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:<name>` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
@@ -131,7 +131,7 @@ export function principalOf(invocation: RemoteInvocation | undefined): Principal
### 方法级访问控制
资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。
资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段或 resolver,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。
#### 与 RBAC 的对应
@@ -165,7 +165,7 @@ export interface TypertResourceMap {}
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
// Runtime registries in typert
ctx.typert.resources.register(kind, { field }): TypertDisposer
ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
@@ -213,11 +213,11 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
| 规则 | 内容 |
|---|---|
| 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 |
| 写法 | 方法必须手工点名资源时写 `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })`;`session/page` 与 `session/follow` 则依赖已注册的 `session` 默认 resolver;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 |
| 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource<SessionId>` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource<WorkspaceId>` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 |
| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` |
| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 |
| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent |
| 默认提取声明 | 资源所属领域登记 `{ field }` 或 `{ resolve }`,两者互斥。SessionController 登记 `resolveSessionResource`;WorkspaceController 登记 `workspaceId`。重复种类被拒绝;lookup 和 Host Context 的资源声明独立于默认规则 |
| 解析优先级 | 每个种类依次采用方法显式 reader、lookup 与 Host Context 身份、注册的默认字段或 resolver。字段读取同名顶层参数或首个对象参数。resolver 在 lookup 解析前调用一次,接收已校验的命名 `args` 及有序参数描述。默认规则返回一个非空字符串或 undefined,数组非法 |
| 参数来源 | 编译 codec 移除未声明的对象字段;SRC fallback 只检查 JSON 安全性,不移除字段。解析和分派使用相同的已校验值。Session resolver 拒绝同时包含 sessionId 与 address 的请求对象,即使两者相同。lookup 和 Context 身份在授权完成前保持 wire 值 |
| 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` |
| 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) |
| 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 |
@@ -228,7 +228,7 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
#### 端点的资源
下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段:
下表列出 #3854 读者用到的端点及其资源来源。Session 历史端点使用注册的 Session resolver;特殊方法可用显式 reader 覆盖默认规则:
| 端点 | 资源 | 解析来源 | 方法内过滤 |
|---|---|---|---|
@@ -241,16 +241,18 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
| `llm/listProviders` | `global` | 无资源 | 否 |
| `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 |
| `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 |
| `session/attachment` | session:`args.request.sessionId` | 默认字段 | 否 |
| `skills/list` | session:`args.request.sessionId` | 默认字段 | 否 |
| `session/fork` | session:`args.request.sessionId` | 默认字段 | 否 |
| `session/prompt` | session:`args.request.sessionId` | 默认字段 | 否 |
| `session/cancel` | session:`args.request.sessionId` | 默认字段 | 否 |
| `session/projections` | session:`args.request.sessionId` | 默认字段 | 否 |
| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | `resources` | 否 |
| `session/follow` | 同上 | `resources` | 否 |
| `session/attachment` | session:`args.request.sessionId` | 默认 resolver | 否:一条 Session 上的授权允许读取整个附件存储 |
| `skills/list` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/fork` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/prompt` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/cancel` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/projections` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | 默认 resolver | 否 |
| `session/follow` | 同上 | 默认 resolver | 否 |
| `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 |
对于 `session/attachment`,`sessionId` 指定 Gateway 检查的资源,不限制可读取哪张图片。该检查放行后,图片可以属于其他 Session 或不属于任何 Session,处理函数也不检查请求中的 Session 是否存在。policy 作者必须把此 action 视为整个附件存储的读取权限,具体规则见[图片访问规则](2026-10-23-store-scoped-image-reads.zh.md)。
subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。
不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent `prompt` 的请求字段为 `parentSessionId` / `childSessionId`,自动解析不到会话,解析为 `global`,因此不得放进任何 `global` 级 role;`session/search` 在方法内按会话逐项过滤命中。`subagents/list` 不是 Remote 端点(`SubagentRuntime.list()` 未标 `@Remote`)。
@@ -392,7 +394,7 @@ const childPrompt = child => ({ action, args }) =>
| 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` |
| 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid`;JSON 参数校验失败:`gateway/input-invalid`(owner 与非 owner 相同,先于访问检查) |
| 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — |
| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` |
| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次注册的默认字段或 resolver;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` |
| 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 |
鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。
@@ -600,7 +602,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
**拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。
**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。
**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段或 resolver。
**按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。
@@ -635,8 +637,8 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
| 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec |
| policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec |
| 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec |
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
| 资源解析 | 默认字段或 resolver 得到一个资源,默认 resolver 返回数组为非法;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,只有编译 codec 才移除未声明字段,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段或 resolver,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
| 转发事件 | 非 owner 的 `$events` 流只在 `event:<name>` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-19-cua-profile-consent-storage.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-19-cua-profile-consent-storage.md
/agent-note-durable-computer-use-consent-within-one-profile:
en: b2aa270d586efd93
zh: 99606fa28dc4a752
/agent-note-durable-computer-use-consent-within-one-profile/problem:
en: f2c70036a57ddb41
zh: 9bbbf3dd09d486f5
/agent-note-durable-computer-use-consent-within-one-profile/decision:
en: 1df102f3d0440a6a
zh: 3456af8e552729a3
/agent-note-durable-computer-use-consent-within-one-profile/alternatives-considered:
en: 2af74afa3ecaf3b9
zh: 96f7f43c5d1150df
/agent-note-durable-computer-use-consent-within-one-profile/consequences:
en: 66fe232589e46d64
zh: fc69831be76248d9
@@ -0,0 +1,31 @@
# Agent Note: Durable Computer Use consent within one profile
Status: implemented
English | [中文](2026-10-19-cua-profile-consent-storage.zh.md)
## Problem
Application grants stored as plugin configuration make each authorization change depend on configuration reconciliation. Process-local conversation decisions also disappear when the same conversation resumes after a restart. A shared in-memory storage handle cannot observe another process revoking permission.
## Decision
The CUA owner uses the existing domain storage service, with one domain per canonical profile directory. It stores long-term grants and conversation decisions by native application identity. Conversation IDs scope permission without turning it into permission for every conversation. The native identity policy remains owned by [application consent](2026-09-26-cua-application-consent.md); this note replaces only that note's storage and restart-lifetime choices.
Every operation opens fresh domain state while holding the profile's existing file-lock primitive and closes the domain before releasing the lock. Native dispatch and revocation use a separate lock for the selected application. Storage reads and unrelated applications remain available during a native call. Revocation cancels this process's affected work first, then commits after other processes' already-admitted operations have finished. A completed revocation therefore leaves no operation using the old authority. App-wide revocation generations and per-conversation decision revisions prevent an old handle or delayed answer from restoring revoked access.
The JSON backend remains the shipped default. The domain name contains the SHA-256 digest of the canonical profile directory, isolating profiles that share one storage root. Legacy `allowedApps` is imported once; a persisted migration marker prevents an old configuration from restoring a revoked grant. Users can remove that configuration after checking the migrated list. Corrupt or unavailable profile storage fails closed. A composition without a profile has only temporary conversation state.
## Alternatives considered
**Keep authorization in configuration.** It preserves existing storage but retains configuration-write and HMR coupling for runtime application decisions.
**Keep one domain open and rely on change events.** Domain notifications are process-local. Another host could continue to use stale grants after revocation.
**Add a new watcher, revision file or authorization broker.** The existing domain and file-lock primitives provide fresh reads and serialized operation admission without a new shared storage or IPC framework. Operations on the same application are serialized across processes; other applications and their conversation decisions remain independent.
## Consequences
File locks coordinate processes on one machine and PID namespace. A network-mounted profile shared across hosts is unsupported. Revocation may wait for another process's native call to settle. No timer establishes authorization validity.
The permission page still omits conversation-only grants and reads current state on entry, focus, actions and reconnection. Storage changes do not emit configuration events. System permissions remain owned by macOS. Tests cover restart retention, profile isolation, another process's write, cancellation, corrupted data, legacy import and non-resurrection after revocation. Stored conversation records currently require explicit retention management rather than automatic eviction.
@@ -0,0 +1,31 @@
# Agent Note: 同一 profile 内的持久 Computer Use 授权
Status: implemented
[English](2026-10-19-cua-profile-consent-storage.md) | 中文
## 问题
把应用许可保存为插件配置,会让每次授权变更依赖配置重组。只保存在进程内的会话决定,也会在重启后恢复同一对话时丢失。长期持有的内存存储句柄无法观察另一个进程的撤销操作。
## 决策
CUA 使用现有域存储服务,每个 profile 的真实目录对应独立存储域,按本机应用身份保存长期许可和会话决定。对话 ID 限定权限范围,不把本会话许可扩展为所有对话的许可。本机身份规则仍由[应用授权](2026-09-26-cua-application-consent.zh.md)负责,本记录仅替代其中的存储和重启后有效期选择。
每个操作使用现有文件锁,在 profile 锁内打开并读取最新存储状态,关闭域后才释放锁。原生操作和撤销使用单独的目标应用锁;原生调用期间仍可读取存储或访问其他应用。撤销先取消本进程中受影响的工作,再等待其他进程已获准执行的操作结束后提交。因此,撤销完成后不会留下仍使用旧授权的操作。应用级撤销代次和各对话的决定版本,阻止旧句柄或迟到回答恢复被撤销的访问权限。
随附配置继续使用 JSON 后端。域名包含 profile 真实目录的 SHA-256 摘要,隔离共用存储根目录的不同 profile。旧 `allowedApps` 只导入一次,持久化迁移标记阻止旧配置恢复已撤销的许可。用户确认列表迁移完成后可删除旧配置。profile 存储损坏或不可用时拒绝访问。没有 profile 的组合只保留临时会话状态。
## 考虑过的替代方案
**继续将授权保存在配置中。** 可以复用旧存储,但运行时应用决定仍依赖配置写入和 HMR。
**长期打开域并依赖变更事件。** 域事件只在进程内传播,另一个宿主可能在撤销后继续使用旧许可。
**增加新的监听器、版本文件或授权代理。** 现有域存储和文件锁已经能提供最新状态与串行准入,无须增加通用存储或 IPC 框架。不同进程对同一应用的操作串行执行,其他应用及其会话决定保持独立。
## 后果
文件锁协调同一电脑及 PID 命名空间中的进程,不支持跨主机共用网络目录。撤销可能等待其他进程的原生调用结束。授权是否有效不由计时器决定。
权限页仍不显示仅对单个会话生效的许可,在进入页面、返回窗口、完成操作和重连后读取最新状态。存储变更不发送配置事件,系统权限仍归 macOS 管理。测试覆盖重启恢复、profile 隔离、其他进程写入、取消、数据损坏、旧配置导入和撤销后不恢复。已保存的会话记录目前需要显式管理保留期限,不自动淘汰。
@@ -0,0 +1,28 @@
# Bilingual-pair consistency record for 2026-10-21-session-history-stream-and-snapshot.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-21-session-history-stream-and-snapshot.md
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation:
en: 0c9a7bc6591feb16
zh: 3606a2c1caffd36f
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/problem:
en: ac1583e7ae1377f8
zh: c60bdbda9ee89144
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision:
en: 45c63df5dac389eb
zh: d72a5bcb94de22f1
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/reads-and-subscriptions:
en: 38df4f3c4fba2cc9
zh: a34f3376faf26f0c
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/follow-ordering-and-ownership:
en: 352ff87a3e9cd110
zh: 4ac6366cecfc08a3
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/state-and-wait:
en: a87c7427582e6b03
zh: b28d2a73b36eb27f
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/alternatives-considered:
en: c5fb06ab9df9e7ad
zh: b20adf151018111c
/agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/consequences:
en: dc1da262b4911a31
zh: 2a4e503696fc477b
@@ -0,0 +1,59 @@
# Agent Note: Session snapshots, passive subscriptions, and Web aggregation
Status: implemented
English | [中文](2026-10-21-session-history-stream-and-snapshot.zh.md)
## Problem
A point read, an event subscription, and a Web conversation opening have different lifetimes. Combining them inside one history controller makes passive observation perform unnecessary reads and gives each aggregate its own acquisition logic. Reading before subscribing also creates a gap that requires another read to repair. The APIs need independent operations that can compose without losing events or duplicating ownership.
## Decision
`SessionController` is the single Session Remote Service. It owns ordinary `HistoryController`, `StateController`, and `WebController` instances, not three additional Cordis Services or Remote namespaces. The implementation separates six operations: history snapshot and observation, projection snapshot and observation, and state snapshot and observation. Follow and Wait compose those capabilities for their callers; the existing `page` entry forwards to the history reader.
### Reads and subscriptions
History reads return chronological records, `hasMore`, and an inclusive cursor. An omitted `throughSeq` selects the observed log end; `beforeSeq` selects older records without changing that read cut. Projection reads return exact sequenced values, sequence-free cache hints when migration is required, or null when the Session is absent. Live projection reads avoid persistence metadata access.
`usingObservation({ sessionId, projection }, signal, callback?)` acquires the existing disposable SessionObservation. A callback borrows it until the callback settles; without a callback the caller owns disposal. Ordinary history, state, and Wait output reads do not request projections. Follow, exact projection reads, and subagent history descriptor validation request them. No read activates an Agent implicitly.
`observeEvent` and `observeProjections` install listeners without acquiring an Observation. Their initial `ready` confirms subscription, not a snapshot cursor, existence, parent-child validity, or Agent readiness. History establishes continuity from its first received event; projection updates replace individual keys and may share a sequence number. `observeState` instead begins with a complete state read and coalesces later Session and Agent invalidations.
History, history observation, and Follow accept Address requests; projection and state operations accept Session IDs. All internal readers and listeners use Session IDs. History and Follow validate Address against their opening Observation at the Controller layer, including the existing subagent descriptor rules. Pure history observation does not acquire a read for entity validation. Gateway resource authorization remains independent: the registered Session resolver extracts the target ID from the request before access is decided.
### Follow ordering and ownership
Follow installs history and optional assistant listeners first, then acquires one projected Observation. It captures the assistant baseline and binding-local arrival ordinal synchronously, sets both cuts, publishes the opening snapshot, and consumes the shared output queue. History notifications at or before the snapshot cursor and assistant notifications at or before the ordinal are discarded at consumption. Later notifications remain ordered without a second history read, background merge pumps, or a tail repair introduced by the opening itself.
`session/created` contributes constructor seed events through the same history buffer. The adapter retains the supplied Session and its captured end position, then reads that in-memory prefix when consumed; it does not acquire another Observation. Each binding owns its subscriptions; a shared output queue belongs to Follow rather than to either listener.
Follow activates only when the caller passes `activate: true`; the Web Client does so explicitly. Background promotion starts once the opening is computed and before it is delivered, and does not promise Agent readiness. Ordinary promotion reuses the existing Agent controller and receives a separately retained Observation. Either Follow releases its opening Observation once the opening is computed, before yielding it; activation continues on the separately retained lease, so the cold entry's reference count drops to that lease alone. Subagent Sessions do not use ordinary promotion.
### State and Wait
State contains the event cursor, active and running flags, the complete readable queue or null, and pending-input kinds. It exposes neither a header nor an internal revision. Gateway owns unsettled Remote events; State samples their detached metadata and classifies approval, question, or other input without copying a request registry.
Wait evaluates complete states, confirms active-Agent quiescence with `whenIdle`, and rejects stale observations using listener-local revisions and Agent/Session identity checks. Session and Agent events wake reads immediately; while waiting, a one-second recheck detects pending input that produces no such event. This avoids adding a Gateway change event, at the cost of polling and bounded sampling delay rather than instantaneous notification. `observeState` does not poll, so pending-only changes need another state invalidation to become visible there.
Wait returns idle, input-required, or timeout with the last state it accepted and bounded assistant output. Output contains only `assistant/message` records in `(afterSeq, state.cursor]`, newest first, and reports truncation only when another eligible record exists. The caller's cancellation signal controls that output read independently of the expired wait deadline. A wait that accepts no state before its deadline fails instead of inventing a state.
## Alternatives considered
**Read history, then subscribe and repair the gap.** Subscription-before-read already captures opening-time increments. A second read adds acquisition and replay obligations without improving that ordering.
**Give pure subscriptions an Observation or ready cursor.** These values conflate subscription readiness with an independently selected read cut. Callers that need both subscribe and buffer first, then read and discard covered increments.
**Keep separate Remote Services for the implementation controllers.** The classes divide behavior, not external service identity. One Session Service owns their composition, resource policy, and Remote entry points.
**Have Gateway interpret pending input or emit Session status.** Gateway only owns Remote event transport and unsettled-event metadata. State owns classification; Wait's periodic sampling avoids adding a transport notification solely for Session waiting.
**Merge assistant and history with independent asynchronous pumps.** One caller-owned buffer retains cross-source arrival order without extra pump cancellation or queue ownership. Snapshot construction and per-binding buffering retain distinct lifetimes.
## Consequences
The standard reads and Web aggregate share one Observation acquisition path, while pure subscriptions remain read-free. The old combined History controller is removed; still-used pagination, validation, and wire helpers remain at their original path. Cancellation, consumer termination, failed opening reads, and Context disposal release subscriptions and owned leases without cancelling Agent execution.
The [Observation decision](2026-08-25-session-observations-and-projection-owned-client-state.md) remains the authority for exact read cuts and projection derivation. The [history and Remote transport decision](2026-08-18-session-history-and-event-transport.md) retains ownership of Client reconnection and transport lifetimes. This decision refines Session API composition rather than replacing either broader decision.
The migrated history, cold-read, and transport tests exercise the new classes, including cursor overlap, assistant ordinal cuts, sequence gaps, subagent validation, and teardown. Slow consumers can still retain queued notifications; silent dropping is not a supported recovery strategy. Independent completion-acknowledged activation is not implemented by these reads or by Follow's background activation option.
@@ -0,0 +1,59 @@
# Agent Note: Session 快照、纯订阅与 Web 聚合
Status: implemented
[English](2026-10-21-session-history-stream-and-snapshot.md) | 中文
## Problem
定点读取、事件订阅和 Web 会话开场具有不同的生命周期。把它们合并在同一个历史控制器中,会使被动观察执行不必要的读取,并让每个聚合各自维护获取数据的逻辑。先读后订阅还会产生需要再次读取来修复的缺口。接口需要相互独立的操作,使组合既不丢事件,也不重复资源所有权。
## Decision
`SessionController` 是唯一的 Session Remote Service。它持有普通的 `HistoryController`、`StateController` 和 `WebController` 实例,而不是额外的三个 Cordis Service 或 Remote namespace。实现分离六个操作:历史快照与观察、投影快照与观察、状态快照与观察。Follow 和 Wait 为调用方组合这些能力;已有的 `page` 入口转发到历史读取器。
### 读取与订阅
历史读取返回时间正序的记录、`hasMore` 和包含末项的游标。省略 `throughSeq` 时选择观察到的日志末尾;`beforeSeq` 只选择更早的记录,不改变读取点位。投影读取返回精确的有序值、需要迁移时无序号的缓存提示,或在 Session 不存在时返回 null。live 投影读取不访问持久化元信息。
`usingObservation({ sessionId, projection }, signal, callback?)` 获取已有的可释放 SessionObservation。有回调时租约借给回调直到其结束;无回调时由调用方释放。普通历史、状态及 Wait 输出读取不请求投影。Follow、精确投影读取和子会话历史 descriptor 校验请求投影。读取不会隐式激活 Agent。
`observeEvent` 和 `observeProjections` 安装监听而不获取 Observation。首个 `ready` 确认订阅生效,不代表快照游标、实体存在、父子关系有效或 Agent 就绪。历史从首个收到的事件建立连续性;投影更新替换单个键,多个更新可以共享序号。`observeState` 则从完整状态读取开始,并合并后续 Session 和 Agent 失效通知。
历史、历史观察和 Follow 接收 Address 请求;投影和状态操作接收 Session ID。所有内部读取器和监听器都使用 Session ID。History 和 Follow 在 Controller 层以开场 Observation 校验 Address,包括已有的子会话 descriptor 规则。纯历史观察不为实体校验获取读取。Gateway 资源授权与之独立:注册的 Session 解析器在访问判定前从请求提取目标 ID。
### Follow 顺序与所有权
Follow 先安装历史和可选 assistant 监听,再获取一份带投影的 Observation。它同步捕获 assistant 基线和绑定局部的到达序号,设置两种截断点,发布开场快照,然后消费共享输出队列。消费时丢弃序号不超过快照游标的历史通知,以及到达序号不超过截断点的 assistant 通知。后续通知保持顺序,无需第二次历史读取、后台合并任务,或由开场自身引入的尾部修复。
`session/created` 经同一历史缓冲提供构造期种子事件。适配器保留收到的 Session 及捕获的末尾位置,在消费时读取对应内存前缀,不获取另一份 Observation。每个绑定拥有自己的订阅;共享输出队列属于 Follow,而不是任一监听器。
只有调用方传入 `activate: true` 时 Follow 才激活;Web Client 显式传入该参数。后台提升在开场计算完成后、交付之前启动,不承诺 Agent 已就绪。普通提升复用已有 Agent controller,并接收单独 retain 的 Observation。两种 Follow 都在开场计算完成后、交付前释放开场 Observation;激活则继续使用单独 retain 的租约,因此冷 entry 的引用计数只剩那一份。子会话不走普通提升。
### 状态与 Wait
State 包含事件游标、active 与 running 标志、完整可读队列或 null,以及待输入种类。它不暴露 header 或内部 revision。Gateway 拥有未结算的 Remote 事件;State 采样独立元数据,将输入分类为 approval、question 或 other,不复制请求注册表。
Wait 判断完整状态,通过 `whenIdle` 确认活跃 Agent 已静止,并通过监听器局部 revision 和 Agent/Session 身份检查拒绝过时观察。Session 和 Agent 事件立即唤醒读取;等待期间每秒重查一次,检测没有产生上述事件的待输入请求。这避免新增 Gateway change 事件,代价是轮询和采样延迟,而非即时通知。`observeState` 不轮询,因此仅有 pending 变化时,需要另一项状态失效通知才会显示更新。
Wait 返回 idle、input-required 或 timeout,以及最后一次被接受的状态和有界 assistant 输出。输出仅包含 `(afterSeq, state.cursor]` 内从新到旧的 `assistant/message` 记录,只有存在另一条符合条件的记录时才报告截断。输出读取使用调用方的取消信号,不受已过期的等待截止信号控制。截止前没有任何状态通过校验的 wait 会失败,不构造虚假的状态。
## Alternatives considered
**先读历史,再订阅并修复缺口。** 先订阅后读取已经捕获开场期间的增量。第二次读取增加资源获取和重放责任,却不改善这个顺序。
**给纯订阅提供 Observation 或 ready 游标。** 这会混淆订阅就绪和独立选取的读取点位。需要两者的调用方先订阅并缓冲,再读取并丢弃已覆盖增量。
**为各实现 Controller 保留独立 Remote Service。** 这些类划分行为,而不是外部服务身份。一个 Session Service 拥有其组合、资源策略和 Remote 入口。
**让 Gateway 解释待输入状态或发送 Session status。** Gateway 只拥有 Remote 事件传输和未结算事件元数据。State 拥有分类;Wait 定期采样,避免仅为 Session 等待增加传输通知。
**用独立异步任务合并 assistant 与历史。** 调用方拥有的单个缓冲保留跨来源到达顺序,不引入额外任务取消或队列所有权。快照构造和各绑定缓冲仍保留不同生命周期。
## Consequences
标准读取和 Web 聚合共享同一 Observation 获取路径,纯订阅不读取数据。旧的合并 History controller 已删除;仍使用的分页、校验和 wire 辅助函数留在原路径。取消、消费者结束、开场读取失败和 Context 释放都会释放订阅及拥有的租约,而不取消 Agent 执行。
[Observation 决策](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)仍拥有精确读取点位和投影派生规则。[历史与 Remote 传输决策](2026-08-18-session-history-and-event-transport.zh.md)继续拥有 Client 重连和传输生命周期。本决策细化 Session API 组合,而不取代上述更广泛的决策。
迁移后的历史、冷读取和传输测试覆盖新类,包括游标重叠、assistant 到达序号截断、序号缺口、子会话校验和释放。慢消费者仍可能积累队列通知;静默丢弃不是支持的恢复策略。这些读取以及 Follow 的后台激活选项均未实现独立的、确认完成后返回的激活接口。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-23-independent-session-forks.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-23-independent-session-forks.md
/agent-note-ordinary-forks-have-no-durable-creator-relationship:
en: 543a84d51e8aea24
zh: 24aeec7c3cdf5900
/agent-note-ordinary-forks-have-no-durable-creator-relationship/problem:
en: d2edaafdd83e5974
zh: 57ce9ad368c404c2
/agent-note-ordinary-forks-have-no-durable-creator-relationship/decision:
en: aae0053e9dc3be9f
zh: bd37095fd5c43c3d
/agent-note-ordinary-forks-have-no-durable-creator-relationship/compatibility:
en: 8393e8526f927134
zh: c957cbde9dc6712d
/agent-note-ordinary-forks-have-no-durable-creator-relationship/alternatives-considered:
en: 748aa21283186e02
zh: 51d3b0207b250784
/agent-note-ordinary-forks-have-no-durable-creator-relationship/consequences:
en: adcb06ccf7503e55
zh: 30e02c6104eefe01
@@ -0,0 +1,35 @@
# Agent Note: Ordinary forks have no durable creator relationship
Status: implemented
English | [中文](2026-10-23-independent-session-forks.zh.md)
## Problem
Using `parentSession` for both copied history and delegation makes an ordinary fork appear to be a subagent. Consumers can emit false child-start notifications, hide resumable sessions, or impose source-relative ordering. Removing the writer alone leaves historical ordinary forks misclassified and exposes a V4 reader assumption: inherited delivery acknowledgements require a parent id even though the validated seed range already identifies copied events.
## Decision
Ordinary forks preserve their complete selected prefix, `isSeeded`, `inheritedEventCount`, and inherited end-seed marker, but record no source id. Subagents retain their direct creator, including when their backend copies history. `origin: subagent` supplies coarse classification; a valid own-range descriptor separately supplies continuation identity. Historical parent metadata remains readable and unchanged. Runtime Agent ownership remains governed by the [explicit identity decision](2026-08-31-explicit-agent-runtime-identity.md).
The V4 reader permits another Session's delivery id only inside the validated inherited range. Own-range markers still require the current id, and every active-generation `throughSeq` must precede its marker. These checks do not require a persistent source relationship.
## Compatibility
This is a same-version behavior change with no declared persistence-type change. The generated inventory reports no schema transitions, so no new type acknowledgement or finalization checkpoint is needed. Existing ordinary forks and subagent logs retain their meanings, and the new reader accepts both parent-bearing and parentless seeds without rewriting either. Frozen schemas and historical generations remain untouched.
The released `dsh-v0.2.0-rc.2` validator and the reviewed master baseline share the parent-dependent delivery check. Before changing that check, real plain and compressed JSONL probes verified rejection through both read and write opens for parentless ordinary and nested forks, with byte-identical files afterward. Rejection occurs during completed-log relationship validation, before a handle is granted or tail repair runs. Older readers report corruption rather than an upgrade diagnostic; that diagnostic limitation is explicit in the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.md). Parentless seeds without active inherited delivery markers remain readable. This observed admission behavior meets the [safe-refusal rule](../process/2026-10-08-session-reader-compatibility-review.md); optionality alone is not the justification.
## Alternatives considered
**Keep the source under another field.** A replacement such as `forkSourceId` preserves the durable relationship the user chose to remove, along with its downstream maintenance cost.
**Erase parent metadata from existing logs.** Historical mutation loses recorded information and violates immutable-generation guarantees. Consumers distinguish old ordinary forks without cleanup.
**Bump the format solely because older readers reject some new logs.** The existing inherited range and complete-log check prevent unsafe execution and truncation. A global bump and adjacent copy add no protection to this transition; same-version refusal has a documented diagnostic limitation.
## Consequences
Ordinary-fork source tracing is intentionally unavailable in new data. Manual ordering preserves saved positions and treats new forks like other ordinary sessions. Operation-local source access preserves title increments and workspace selection. The [side-session proposal](../../proposed/feature/2026-07-08-interactive-side-sessions.md) retains its separate handback idea but cannot use a stored ordinary-fork parent. Runtime ownership, projection-cache identity, and Team membership decisions remain independently useful and are not superseded.
Verification covers real fork/persist/reopen paths for ordinary, nested, historical, and subagent seeds, rejection without writes for foreign own-range delivery and invalid coordinates, SDK notification classification, ACP resume, client ordering, and title generation. TypeScript and Python recorded SDK scenarios create ordinary and nested forks plus historical parent-bearing metadata while retaining the expected real-subagent lifecycle.
@@ -0,0 +1,35 @@
# Agent Note: 普通 fork 不保留持久创建者关系
Status: implemented
[English](2026-10-23-independent-session-forks.md) | 中文
## 问题
同时用 `parentSession` 表示复制历史和委派,会让普通 fork 看起来像子代理。消费方可能发送错误的子代理启动通知、隐藏可恢复会话,或施加相对来源的排序。仅删除写入仍会误分类历史普通 fork,并暴露 V4 reader 的一项假设:继承投递确认要求父 ID,尽管已验证的 seed 区间已经标识复制事件。
## 决策
普通 fork 保留完整的选定前缀、`isSeeded`、`inheritedEventCount` 及继承结束标记,但不记录来源 ID。子代理保留直接创建者,包括 backend 复制历史的情况。`origin: subagent` 提供粗粒度分类;有效的自身区间 descriptor 单独提供继续运行身份。历史父元数据保持可读且不变。运行时 Agent 归属仍遵循[显式身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)。
V4 reader 仅在已验证的继承区间中允许其他 Session 的投递 ID。自身区间标记仍要求当前 ID,每个活动代际的 `throughSeq` 必须早于其标记。这些检查不需要持久来源关系。
## 兼容性
这是没有声明持久化类型变化的同版本行为变更。生成清单报告没有 schema 转换,因此不需要新增类型确认或定稿检查点。已有普通 fork 和子代理日志保留其含义,新 reader 接受有父关系及无父关系的 seed,且不改写任一种。冻结 schema 与历史代际保持不变。
已发布的 `dsh-v0.2.0-rc.2` 校验器与所审查 master 基线具有相同的依赖父关系的投递检查。修改此检查之前,真实的普通及压缩 JSONL 探针验证:无父关系的普通和嵌套 fork 在 read、write 打开时都被拒绝,之后文件字节完全一致。拒绝发生在完整日志的关系校验期间,早于授予句柄或尾部修复。旧 reader 报告损坏而非升级诊断;[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.3/independent-session-forks/guide.zh.md)明确说明这一诊断限制。没有活动继承投递标记的无父关系 seed 仍然可读。这一已观察到的接纳行为满足[安全拒绝规则](../process/2026-10-08-session-reader-compatibility-review.zh.md);可选字段本身不是依据。
## 考虑过的替代方案
**用其他字段保留来源。** `forkSourceId` 等替代字段保留了用户决定移除的持久关系及其下游维护成本。
**清除已有日志的父元数据。** 修改历史会丢失已记录信息,并违反代际不可变保证。消费方可以在不清理的情况下区分旧普通 fork。
**仅因旧 reader 拒绝部分新日志就升级格式。** 现有继承区间和完整日志检查阻止不安全执行与截断。全局升级和相邻复制不会为这次转换增加保护;同版本拒绝的诊断限制已有明确记录。
## 影响
新数据有意不支持普通 fork 的来源追踪。手动排序保留已保存位置,将新 fork 当作其他普通会话处理。操作期间访问来源保留标题递增和工作区选择。[侧会话提案](../../proposed/feature/2026-07-08-interactive-side-sessions.zh.md)保留独立的交回设想,但不能使用普通 fork 的持久父字段。运行时归属、投影缓存身份和 Team 成员关系决策仍分别有用,未被取代。
验证覆盖普通、嵌套、历史及子代理 seed 的真实 fork/持久化/重新打开路径,自身区间外来投递及非法坐标的无写入拒绝,SDK 通知分类、ACP 恢复、客户端排序与标题生成。TypeScript 和 Python 录制 SDK 场景创建普通及嵌套 fork 和携带历史父元数据的会话,同时保留真正子代理的预期生命周期。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-23-store-scoped-image-reads.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-23-store-scoped-image-reads.md
/agent-note-image-reads-use-attachment-store-access:
en: 31c8a0d9016d99af
zh: 05db308dec85db67
/agent-note-image-reads-use-attachment-store-access/problem:
en: ff8886cfa4634163
zh: 46dfdd50638b64c2
/agent-note-image-reads-use-attachment-store-access/decision:
en: 439a3277ee3b169d
zh: c9f1e10254850844
/agent-note-image-reads-use-attachment-store-access/alternatives-considered:
en: 0c7a8319c0c91dc2
zh: 6b7b58b142839571
/agent-note-image-reads-use-attachment-store-access/consequences:
en: a46c3d951077ec7d
zh: 30a0cc5bcb681a21
/agent-note-image-reads-use-attachment-store-access/verification:
en: 89603b3a4c3e12df
zh: f1b01d166f399f87
@@ -0,0 +1,52 @@
# Agent Note: Image reads use attachment-store access
Status: implemented
English | [中文](2026-10-23-store-scoped-image-reads.zh.md)
## Problem
Historical image loading read the requesting Session and searched its event content for every uncached attachment. The search established that the Session referenced the image; it did not locate the bytes, whose storage path already follows from the content-addressed ID. This added work proportional to history length to each image read. The reported loading delay also involved uploads; this finding alone does not identify all of that delay.
## Decision
Session membership is not an image-access restriction. After connection authentication and Gateway permission checks authorize `session/attachment`, every caller may read any image in the same attachment store using a complete `ImageAttachmentRef`, including objects referenced only by other Sessions or not referenced at all. The request retains `sessionId` for the existing Session resource permission check. After that check passes, the handler does not require that Session to exist and does not return `session/not-found` solely because it is absent. No identity receives a separate history-scanning path.
The Client already holds the message's complete image reference and sends it as `attachment` beside `sessionId`. Session Controller delegates directly to the existing `AttachmentStore.readImage(ref)`. Its local provider validates the canonical ID, derives the path within its object directory, verifies SHA-256, and compares the actual format, byte length, width, and height with the supplied reference. A missing, unreadable, corrupt, or mismatched object rejects the operation. No Session history is needed to recover the reference.
The response preserves the supplied reference. Its optional `name` and `originalDimensions` are caller-supplied display information and are not verified against the stored object. The Client's existing historical-image cache continues to own per-Session results and object URLs; the Host adds no image-byte cache.
This rule narrows the readers covered by [Session observations](2026-08-25-session-observations-and-projection-owned-client-state.md) and the [history transport activation policy](2026-08-18-session-history-and-event-transport.md). [Workspace file reads](2026-09-05-workspace-files-service.md) keep their own filesystem authorization.
## Alternatives considered
**Keep a Session-to-image map or projection.** An index removes repeated scans but preserves an access restriction the product does not require, adding derivation, lifetime, and invalidation work.
**Issue signed image tickets.** Tickets add issuance and validation state for the same unnecessary Session-membership restriction.
**Optimize only owner reads.** That gives identities different image-access semantics and leaves the scan cost for permitted non-owner callers without a product requirement.
## Consequences
Knowing a valid image reference and having permission to invoke the endpoint for the supplied Session resource permits reading that object across Sessions. The local ID is the SHA-256 of normalized bytes. For known PNG, JPEG, or WebP bytes that admission preserves unchanged, a caller can calculate the ID, derive the format, byte length, and dimensions, and use an authorized read to test whether the object exists. The required reference fields need not come from a Session log.
Image isolation requires separate Host instances with separate `DSH_HOME` directories. The shipped composition uses one attachment store per Host and does not select a store by Session. Authentication, Gateway action/resource checks, fixed storage paths, digest verification, and explicit failures remain required.
Image reads no longer depend on history length or Session materialization. File reading, integrity verification, encoding, and transport still cost time; this change makes no claim about upload or model inference latency. Existing Session data and fork references need no migration.
## Verification
The read-path invariant is zero Session-history queries or traversal for attachment reads. Coverage must distinguish a permitted cross-Session image read from a denied Gateway call and exercise valid references, invalid IDs, mismatched intrinsic metadata, preserved optional display fields, missing and corrupt objects, old history pages, and inherited fork images. Performance evidence must separate historical scanning, storage work, and transport rather than attribute the entire reported delay to the scan.
The diagnostic is [`image-read.perf.ts`](../../../../packages/api/session-controller/tests/image-read.perf.ts), compiled by its [tsdown configuration](../../../../packages/api/session-controller/tests/image-read.perf.config.ts). The baseline worker pairs the pre-change controller with `{ sessionId, attachmentId: ref.attachmentId }`; the candidate worker pairs the current controller with `{ sessionId, attachment: ref }`. Both use the same image fixtures, workload, and output checks, and run against the same built Local store with `readImage(ref)` verification. From the candidate checkout root, compile and run:
```sh
pnpm exec tsdown --config-loader native --config packages/api/session-controller/tests/image-read.perf.config.ts
(cd packages/api/session-controller && node --expose-gc .dsh-build/image-read/image-read.perf.js --expect-no-history)
```
Run the separately compiled baseline worker without `--expect-no-history`; its diagnostic request must match the baseline controller's ID-only request fields. The baseline negative control adds `--sample typical --expect-no-history` and must fail because that implementation queries and traverses history. The parent process runs each workload in five fresh child processes, each with a loaded Session and a first uncached read of every image. Images are deterministic synthetic 1024 × 768 PNGs; the two workloads contain 120 turns, 960 events, and 8 images, or 2,000 turns, 16,000 events, and 32 images. Fixture writes can leave the operating system's file cache warm.
`controllerMs` covers the direct controller call through base64 return. `storeMs` includes file I/O, SHA-256, and header verification; `snapshotMs` measures history snapshot acquisition separately. `scanAndDispatchMs` is the time before store entry minus `snapshotMs`, so it includes dispatch overhead and is not an isolated scan timer. JSON serialization and loopback HTTP transfer are separate measurements over retained responses. `requestJsonBytes` and `idOnlyRequestJsonBytes` compare serialized request lengths; they do not measure request transmission time. Cold Session restoration, Gateway processing, browser rendering, remote-network latency, upload, and model inference are excluded. PR #7021 records the worker revisions and before/after results under these limits.
The memory endpoints run GC after fixture setup and after all image responses. The Session, admitted image references, and every base64 response remain reachable at the second endpoint. These retained endpoints include setup-owned objects, and peak RSS is cumulative from process start, including fixture generation. They do not measure image-request memory in isolation or impose a retained-memory budget.
@@ -0,0 +1,52 @@
# Agent Note: 图片读取使用附件存储访问权限
Status: implemented
[English](2026-10-23-store-scoped-image-reads.md) | 中文
## Problem
历史图片加载会为每个未缓存的附件读取请求中的 Session,并搜索其事件内容。搜索用于确认 Session 引用了图片;它不负责定位字节,存储路径已经能由内容寻址 ID 推导。每次读图因此增加了与历史长度成正比的工作。用户反馈的加载延迟还涉及上传;这项发现本身不能确定全部延迟的来源。
## Decision
Session 关联不构成图片访问限制。连接身份认证和 Gateway 权限检查允许调用 `session/attachment` 后,任何调用者都可以通过完整的 `ImageAttachmentRef` 读取同一附件存储中的任意图片,包括仅被其他 Session 引用或未被任何 Session 引用的对象。请求保留 `sessionId`,供现有 Session 资源权限检查使用。该检查通过后,处理函数不要求该 Session 存在,也不会仅因其不存在而返回 `session/not-found`。所有身份使用相同的读图路径。
Client 已经持有消息中的完整图片引用,将其作为 `attachment` 与 `sessionId` 一起发送。Session Controller 直接调用已有的 `AttachmentStore.readImage(ref)`。本地提供方校验规范 ID,在自己的对象目录内推导路径,校验 SHA-256,并把实际格式、字节数、宽度和高度与传入引用比对。对象缺失、不可读、损坏或与引用不符时,操作失败。取得引用无需查询 Session 历史。
响应保留传入引用。可选的 `name` 和 `originalDimensions` 是调用方提交的显示信息,不根据存储对象校验。Client 现有的历史图片缓存继续按 Session 管理结果和对象 URL;Host 不增加图片字节缓存。
该规则缩小了 [Session observation](2026-08-25-session-observations-and-projection-owned-client-state.zh.md) 与[历史传输激活策略](2026-08-18-session-history-and-event-transport.zh.md)涵盖的读取范围。[工作区文件读取](2026-09-05-workspace-files-service.zh.md)保留自己的文件系统授权。
## Alternatives considered
**保留 Session 到图片的 Map 或 projection。** 索引可以消除重复扫描,但保留了产品不需要的访问限制,并增加派生、生命周期和失效处理。
**签发图片签名票据。** 票据为同一项不需要的 Session 关联限制增加签发和验证状态。
**只优化 owner 的读取。** 这会让不同身份使用不同的图片访问规则,并让获准调用的非 owner 继续承担扫描成本,没有相应产品需求。
## Consequences
调用方知道有效图片引用,且有权为请求中的 Session 资源调用该接口,就可以跨 Session 读取对象。本地 ID 是规范化字节的 SHA-256。对于准入时原样保留的已知 PNG、JPEG 或 WebP 字节,调用方可以计算 ID,取得格式、字节数和尺寸,并通过获准的读取请求探测对象是否存在。这些必需的引用字段不一定来自 Session 日志。
图片隔离需要独立 Host 实例及各自的 `DSH_HOME` 目录。随附组合在每个 Host 中使用一个附件存储,不会按 Session 选择存储。身份认证、Gateway 操作与资源检查、固定存储路径、摘要校验和明确失败仍是必需的。
图片读取不再依赖历史长度或 Session 物化。文件读取、完整性校验、编码和传输仍有耗时;本次改动不说明上传或模型推理延迟已得到改善。已有 Session 数据和 fork 引用无需迁移。
## Verification
附件读取不得查询或遍历 Session 历史。验证必须区分获准的跨 Session 图片读取与被 Gateway 拒绝的调用,并覆盖正常引用、无效 ID、固有元数据不符、可选显示字段原样保留、缺失和损坏的对象、旧历史页以及 fork 继承图片。性能证据必须区分历史扫描、存储操作和传输,不能把用户反馈的全部延迟归因于扫描。
诊断脚本是 [`image-read.perf.ts`](../../../../packages/api/session-controller/tests/image-read.perf.ts),通过配套的 [tsdown 配置](../../../../packages/api/session-controller/tests/image-read.perf.config.ts)编译。基线 worker 使用改动前的控制器与 `{ sessionId, attachmentId: ref.attachmentId }`;候选 worker 使用当前控制器与 `{ sessionId, attachment: ref }`。两者使用相同图片 fixture、工作量和输出校验,并通过同一套已构建的 Local store 执行 `readImage(ref)` 校验。在候选检出目录的根目录编译并运行:
```sh
pnpm exec tsdown --config-loader native --config packages/api/session-controller/tests/image-read.perf.config.ts
(cd packages/api/session-controller && node --expose-gc .dsh-build/image-read/image-read.perf.js --expect-no-history)
```
运行单独编译的基线 worker 时不带 `--expect-no-history`;诊断请求必须匹配基线控制器仅接收 ID 的请求字段。基线负对照加上 `--sample typical --expect-no-history`,该实现会查询和遍历历史,因此负对照必须失败。父进程为每种工作量运行五个新子进程,每个子进程使用已加载的 Session,并对每张图片执行第一次未缓存读取。图片是确定性合成的 1024 × 768 PNG;两种工作量分别为 120 轮、960 个事件、8 张图片,以及 2,000 轮、16,000 个事件、32 张图片。fixture 写入可能已经预热操作系统的文件缓存。
`controllerMs` 覆盖从直接调用控制器到返回 base64 的时间。`storeMs` 包含文件 I/O、SHA-256 和文件头校验;`snapshotMs` 单独测量取得历史快照的时间。`scanAndDispatchMs` 是进入存储前的时间减去 `snapshotMs`,因此也包含分派开销,不是独立的扫描计时。JSON 序列化和本机回环 HTTP 传输使用保留的响应单独测量。`requestJsonBytes` 与 `idOnlyRequestJsonBytes` 比较序列化后的请求长度,不测量请求传输时间。冷 Session 恢复、Gateway 处理、浏览器渲染、远端网络延迟、上传和模型推理均不在测量范围内。PR #7021 记录 worker 修订,以及在这些限制下测得的修改前后结果。
内存端点在 fixture 准备完成后,以及所有图片响应完成后执行 GC。第二个端点仍保留 Session、已准入的图片引用和每个 base64 响应。这两个保留内存端点包含准备阶段拥有的对象,峰值 RSS 则从进程启动累计,包含 fixture 生成。它们不单独测量图片请求内存,也不规定保留内存预算。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-22-series-start-unchanged-prompt-prefix.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.md
/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node:
en: f62e2980951e0cb3
zh: 3cd4fd49a89fae40
/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/problem:
en: b2b7d5e9a4850ece
zh: cbd91b67969bca02
/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/decision:
en: 8a3404207a36ecfb
zh: d8bc1decefa60a20
/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/verification:
en: 1970ea84337ce9b5
zh: da5b9928606c07c6
/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/alternatives-considered:
en: 2e87f55619f04607
zh: 7ca5a8e9a42a799a
/agent-note-a-series-start-with-an-unchanged-prompt-keeps-the-surviving-system-node/consequences:
en: bf8a9d6c56722efd
zh: 18dfdf8a7b9bcb5c
@@ -0,0 +1,39 @@
# Agent Note: A series start with an unchanged prompt keeps the surviving system node
Status: implemented
English | [中文](2026-10-22-series-start-unchanged-prompt-prefix.zh.md)
## Problem
On an in-history route, a prompt change is appended as a later `system/message`, so node 0 stays stale while the latest surviving node carries the rendering. A surface replacement (compaction, tool-result prune, image offload) starts a new request series. Re-baselining node 0 at a series start while the rendering already matched that latest node emptied the later node and wrote the identical text into node 0. The wire request then differed at its first message, so the provider prefix cache missed from the first token although no prompt text changed.
Real session `session-da55bbc9-…`, turn 710 step 2: `compaction/prune` replaced one tool result (seq 34669, 34670); the next request wrote 7,618 characters identical to the node it emptied (seq 34672, 34673), `request/header` logged reason `series` (seq 34674), and the step billed 722,332 uncached input tokens with 1,152 cache-read tokens (0.16% hit) against about 99.9% on the neighboring steps. A build update at turn 707 had appended the prompt node; nothing changed the rendering at turn 710.
## Decision
`SystemPromptProjection.project` re-baselines node 0 only when the effective text must move: an incapable route, an empty rendering, or a series start whose latest surviving non-empty node text differs from the rendering. When the route is in-history, the rendering is non-empty, and the latest surviving node already holds it, the projection returns no updates and the node stays where the cached history carried it. The loop still records the request series through `request/header` (`series`, or `change` with `startsSeries: true`) and rebuilds declarations as before; when the unchanged-rendering skip coincides with a tool-schema series start, the header records the declaration change without `startsSeries`.
Retry recovery keeps its guarantee: when a retry compaction shadows the latest node, the effective text falls back to an older node, differs from the rendering, and the head is rewritten before users are admitted again. The projection never appends an update after already-admitted users.
## Verification
- [`system-prompt-projection.spec.ts`](../../../../packages/core/agent-loop/tests/system-prompt-projection.spec.ts) pins the no-operation decision for a capable series start whose surviving node already matches.
- [`system-prompt-admission.spec.ts`](../../../../packages/core/agent-loop/tests/system-prompt-admission.spec.ts) pins the wire request: a compaction replacement with an unchanged rendering keeps both system messages, the previous request messages remain a prefix, and the neighboring steps regain the provider prefix.
- The [`in-history-series-start`](../../../../snapshots/session/in-history-series-start/) recorded session replays the shipped headless profile through a prompt change and a later surface replacement, pinning the unchanged-rendering series start to two retained prompt versions and no `system/message` event.
## Alternatives considered
**Always keep the appended node and never re-baseline.** Simpler, but a route that cannot append prompts, or a prompt that must clear, still needs a single current head, and a changed rendering at a series start must not append after users already admitted by a failed attempt. Rejected.
**Skip consolidation whenever the head differs.** A stale head is authoritative again on an incapable destination route, because that route ignores later system nodes; the route capability, not the head text, decides whether the later node is effective. Rejected.
**Stop treating local surface replacements as series starts.** A prune changes one tool result, so the cache loss is local, but image-offload and compaction decisions still legitimately start a series; the projection must not depend on which replacement produced the boundary. Rejected: the projection owns the prompt decision.
**Compare reconstructed requests instead of surface text.** The loop derives requests from surface events, so comparing past wire messages would duplicate request reconstruction and still need the same effective-text comparison. Rejected.
## Consequences
- A capable route can retain earlier prompt versions in model history after a series start; they cost input tokens until a later consolidation or compaction shadows them.
- A tool-schema series start on a route without tool updates no longer writes a `system/message` event, so its header records the declaration change without `startsSeries`. The request still carries the complete active declaration set through the unsupported-route projection (`projectToolUpdates`), while `Session.toolHistory()` keeps its incremental baseline instead of resetting.
- Consolidation still applies when the rendering actually differs: route switches, prompt updates concurrent with compaction, and retries that shadow the latest node.
@@ -0,0 +1,39 @@
# Agent Note: 渲染未变的新序列保留存活的系统节点
Status: implemented
[English](2026-10-22-series-start-unchanged-prompt-prefix.md) | 中文
## Problem
在 in-history 路由上,提示词变化会追加为后续的 `system/message`,因此 node 0 保持陈旧,由最新存活节点承载渲染文本。surface 替换(压缩、工具结果剪枝、图片省略)会开启新的请求序列。在渲染文本已经与该最新节点一致时重写 node 0,会清空该节点并把完全相同的文本写进 node 0。于是 wire 请求的第一条消息发生变化,提供方前缀缓存从第一个 token 起未命中,尽管提示词文本没有任何变化。
真实会话 `session-da55bbc9-…` 第 710 轮第 2 步:`compaction/prune` 替换了一条工具结果(seq 34669、34670);下一次请求写入的 7,618 个字符与它清空的节点完全相同(seq 34672、34673),`request/header` 记录 reason `series`(seq 34674),该步计费 722,332 个未命中输入 token、1,152 个缓存读取 token(命中率 0.16%),而相邻步骤约为 99.9%。第 707 轮的构建更新追加了提示词节点;第 710 轮没有任何东西改变渲染文本。
## Decision
`SystemPromptProjection.project` 只在有效文本必须移动时重写 node 0:不具备能力的路由、空渲染文本,或最新存活非空节点文本与渲染文本不同的新序列开始。当路由为 in-history、渲染文本非空、且最新存活节点已经持有它时,投影不产生任何更新,节点保持在已缓存历史携带它的位置。loop 仍通过 `request/header` 记录请求序列(`series`,或带 `startsSeries: true` 的 `change`),并照旧重建声明;当「渲染未变则跳过」与工具 schema 起因的新序列重合时,header 记录声明变化但不带 `startsSeries`。
重试恢复保持原有保证:重试压缩遮蔽最新节点后,有效文本回退到更旧的节点,与渲染文本不同,于是头节点在用户消息再次被接纳之前重写。投影从不会在已接纳的用户消息之后追加更新。
## Verification
- [`system-prompt-projection.spec.ts`](../../../../packages/core/agent-loop/tests/system-prompt-projection.spec.ts) 固定「能力路由的新序列开始时存活节点文本已一致则不产生操作」的决策。
- [`system-prompt-admission.spec.ts`](../../../../packages/core/agent-loop/tests/system-prompt-admission.spec.ts) 固定 wire 请求:渲染文本未变的压缩替换保留两条系统消息,上一次请求的消息仍构成前缀,相邻步骤重新获得提供方前缀。
- [`in-history-series-start`](../../../../snapshots/session/in-history-series-start/) 录制会话通过 shipped headless profile 回放「提示词变化 + 后续 surface 替换」,固定渲染未变的新序列保留两份提示词版本且不写 `system/message` 事件。
## Alternatives considered
**永远保留追加节点、从不重写。** 更简单,但无法追加提示词的路由、或必须清空的提示词仍需要单一当前头节点;新序列开始时若渲染文本确实变化,也不能在失败尝试已接纳的用户消息之后追加。拒绝。
**只要 head 不同就跳过归并。** 在目标路由变为不具备能力时,陈旧 head 会重新成为权威,因为该路由忽略后续系统节点;决定后续节点是否生效的是路由能力,而不是 head 文本。拒绝。
**让局部 surface 替换不再算新序列。** 剪枝只改一条工具结果的文本,缓存损失是局部的,但图片省略与压缩决定仍合理地开启新序列;投影不应依赖是哪种替换制造了边界。拒绝:提示词决策属于投影。
**比较重建后的请求而不是 surface 文本。** loop 从 surface 事件派生请求,比较历史 wire 消息会重复请求重建,而且仍然需要同样的有效文本比较。拒绝。
## Consequences
- 具备能力的路由在新序列开始后可能在模型历史中保留更早的提示词版本;在被后续归并或压缩遮蔽之前,它们会计入输入 token。
- 不支持工具更新的路由上,工具 schema 变化开启的新序列不再写出 `system/message` 事件,因此其 header 记录声明变化但不带 `startsSeries`。请求仍通过不支持更新的路由投影(`projectToolUpdates`)携带完整的生效声明集合,而 `Session.toolHistory()` 保留其增量基线、不再重置。
- 渲染文本确实不同时归并仍然生效:路由切换、与压缩同时发生的提示词更新,以及遮蔽最新节点的重试。
@@ -15,8 +15,8 @@
en: 2f23fa55e1ce652c
zh: 335695fc654ab1db
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/the-decision-rule:
en: 554d01427ebebee1
zh: 664c9bf0a9337a04
en: bd990101d220c905
zh: 98f54d523b9ffe52
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/presentation-and-accounting:
en: 800d579e2fcf2cce
zh: 1e92354d9161c50a
@@ -24,18 +24,19 @@ The loop records the mode in the session: `RequestContext.systemPromptUpdate` jo
### The decision rule
`SystemPromptProjection.project(rendered, { inHistory, startsSeries })` in `packages/core/agent-loop/src/runtime-context.ts` scans the surviving `system/message` nodes of the current surface on every call. It returns ordered per-node commits. With no surviving system node it reserves the head even for an empty rendering. Effective text comes from the latest non-empty system node, falling back to the head; dormant empty tails neither supply effective text nor need another empty replacement. An empty rendering clears every active system node, regardless of route or series state. For a non-empty rendering on an incapable route or at a new request series, consolidation applies even when the effective text is unchanged. Otherwise matching effective text emits nothing. The operations are:
`SystemPromptProjection.project(rendered, { inHistory, startsSeries })` in `packages/core/agent-loop/src/runtime-context.ts` scans the surviving `system/message` nodes of the current surface on every call. It returns ordered per-node commits. With no surviving system node it reserves the head even for an empty rendering. Effective text comes from the latest non-empty system node, falling back to the head; dormant empty tails neither supply effective text nor need another empty replacement. An empty rendering clears every active system node, regardless of route or series state. For a non-empty rendering, an incapable route or a new request series whose effective text differs consolidates; a series start whose latest surviving node already holds the rendering emits nothing and keeps that node in place. Otherwise matching effective text emits nothing. The unchanged-text case is owned by the [series-start prefix decision](../bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.md). The operations are:
| Route capability | Prefix state | Operation |
|---|---|---|
| none | non-empty rendering, any prefix state | log an empty replacement for each non-empty later system node, then rewrite the first system node with the rendering if needed |
| `in-history` | the current request series continues | append a new `system/message` before the step's `user/message` events; the append alone needs no `request/header` |
| `in-history` | non-empty rendering, a new series starts | log empty replacements for non-empty later system nodes, then rewrite the first system node if needed, even when the latest effective text is unchanged |
| `in-history` | non-empty rendering, a new series starts and the latest effective text differs | log empty replacements for non-empty later system nodes, then rewrite the first system node if needed |
| `in-history` | non-empty rendering, a new series starts with the rendering already current | no operation: the surviving node stays where the cached history carried it |
| any | the rendered prompt is empty | log empty replacements for non-empty later system nodes, then empty the head if needed; no prompt version remains in derived messages |
`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed on a route without `toolUpdate` support. Supported tool additions can accompany a prompt append without starting a series. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start already costs the cache, so consolidation keeps only the current prompt in model history. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt.
`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed on a route without `toolUpdate` support. Supported tool additions can accompany a prompt append without starting a series. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start does not by itself discard the whole provider prefix: a replacement or image-offload decision may invalidate only the tail, so consolidation waits until the effective text must move; when it does, the series is the point to keep only the current prompt in model history and to avoid appending after already-admitted users on a retry. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt.
The first attempt admits the prompt after assembly, an accepted `agent/pre-step` decision, `step/start`, the `agent/request` waterfall, and `prepareCall()`. A rejected or empty first input opens no step. Neither async request phase commits the pending system prompt or accepted users, and cancellation during either commits neither. Every attempt synchronously reconciles the same rendered assembly after its own `agent/request` and `prepareCall()`, appends the accepted user batch only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming through the same prepared call. Retries do not repeat assembly, `agent/pre-step`, or user admission. Reconciliation sees both pre-step compaction (`compaction-basic` with `auto: true`) and recovery compaction, and consolidates non-empty prompt text at the head when either starts a new series. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary.
The first attempt admits the prompt after assembly, an accepted `agent/pre-step` decision, `step/start`, the `agent/request` waterfall, and `prepareCall()`. A rejected or empty first input opens no step. Neither async request phase commits the pending system prompt or accepted users, and cancellation during either commits neither. Every attempt synchronously reconciles the same rendered assembly after its own `agent/request` and `prepareCall()`, appends the accepted user batch only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming through the same prepared call. Retries do not repeat assembly, `agent/pre-step`, or user admission. Reconciliation sees both pre-step compaction (`compaction-basic` with `auto: true`) and recovery compaction, and consolidates non-empty prompt text at the head when either starts a new series whose effective text differs. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary.
An empty head with no active later system node represents no prompt. Dormant empty tails do not supply effective text, so repeated clearing and resume cannot resurrect an older prompt. Restoring non-empty text uses the same admission rule: a continuing capable route may append it; an incapable route or a new series refills the head. Clearing uses ordinary per-node replacements, not a surface delete or an initial empty-head creation.
@@ -24,18 +24,19 @@ Status: implemented
### 决策规则
`packages/core/agent-loop/src/runtime-context.ts` 中的 `SystemPromptProjection.project(rendered, { inHistory, startsSeries })` 每次调用都扫描当前 surface 上存活的 `system/message` 节点。它返回有序的逐节点提交。没有存活的系统节点时,即使渲染文本为空也预留头节点。有效文本取自最新的非空系统节点,没有时回退到头节点;未生效的空尾节点既不提供有效文本,也无需再次以空内容替换。无论路由或序列状态如何,空渲染文本都会清除每个生效的系统节点。不具备能力的路由或新请求序列面对非空渲染文本时,即使有效文本未变也执行归并。除此之外,有效文本相同时不产生事件。具体操作如下:
`packages/core/agent-loop/src/runtime-context.ts` 中的 `SystemPromptProjection.project(rendered, { inHistory, startsSeries })` 每次调用都扫描当前 surface 上存活的 `system/message` 节点。它返回有序的逐节点提交。没有存活的系统节点时,即使渲染文本为空也预留头节点。有效文本取自最新的非空系统节点,没有时回退到头节点;未生效的空尾节点既不提供有效文本,也无需再次以空内容替换。无论路由或序列状态如何,空渲染文本都会清除每个生效的系统节点。面对非空渲染文本,不具备能力的路由或有效文本不同的新请求序列执行归并;新序列开始时若最新存活节点已经持有该渲染文本,则不产生事件并保留该节点。除此之外,有效文本相同时不产生事件。有效文本未变的新序列由[系列开始前缀决策](../bug-fix/2026-10-22-series-start-unchanged-prompt-prefix.zh.md)拥有。具体操作如下:
| 路由能力 | 前缀状态 | 操作 |
|---|---|---|
| 无 | 非空渲染文本,任意前缀状态 | 为每个非空的后续系统节点记录空内容替换,随后按需用渲染文本重写首个系统节点 |
| `in-history` | 当前请求序列延续 | 在该步骤的 `user/message` 事件之前追加新的 `system/message`;仅追加本身不需要记录 `request/header` |
| `in-history` | 非空渲染文本,新序列开始 | 为非空的后续系统节点记录空内容替换,再按需重写首个系统节点,即使最新有效文本未变也执行 |
| `in-history` | 非空渲染文本,新序列开始且最新有效文本不同 | 为非空的后续系统节点记录空内容替换,再按需重写首个系统节点 |
| `in-history` | 非空渲染文本,新序列开始但渲染文本已经是当前文本 | 不产生操作:存活节点保持在已缓存历史携带它的位置 |
| 任意 | 渲染后的提示词为空 | 为非空的后续系统节点记录空内容替换,再按需清空头节点;派生消息中不保留任何提示词版本 |
`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、不支持 `toolUpdate` 的路由上可见工具 schema 集合发生了变化。受支持的工具添加可以与提示词追加一起发生,无需开启新序列。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始已经付出了缓存代价,因此归并让模型历史只保留当前提示词。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。
`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、不支持 `toolUpdate` 的路由上可见工具 schema 集合发生了变化。受支持的工具添加可以与提示词追加一起发生,无需开启新序列。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始本身并不必然使整段提供方前缀作废:替换或图片省略决定可能只使尾部失效,因此归并会等到有效文本必须移动时才执行;此时借助该序列只保留当前提示词,并避免重试时在已接纳的用户消息之后追加指令。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。
首次尝试在组装、被接纳的 `agent/pre-step` 决策、`step/start`、`agent/request` waterfall 与 `prepareCall()` 之后才接纳提示词。被拒绝或为空的首次输入不打开步骤。两个异步请求阶段都不提交待处理的系统提示词与已接纳用户消息,在任一阶段取消都不会提交这两者。每次尝试都在各自的 `agent/request` 与 `prepareCall()` 之后同步协调同一份已渲染组装结果、仅在首次尝试追加已接纳用户批次、按需记录 header/context、派生并冻结请求,再通过同一个已准备调用发起流式请求。重试不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 压缩(`auto: true` 的 `compaction-basic`)与恢复压缩,并在任一种压缩开启新序列时将非空提示词文本归并到头部。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。
首次尝试在组装、被接纳的 `agent/pre-step` 决策、`step/start`、`agent/request` waterfall 与 `prepareCall()` 之后才接纳提示词。被拒绝或为空的首次输入不打开步骤。两个异步请求阶段都不提交待处理的系统提示词与已接纳用户消息,在任一阶段取消都不会提交这两者。每次尝试都在各自的 `agent/request` 与 `prepareCall()` 之后同步协调同一份已渲染组装结果、仅在首次尝试追加已接纳用户批次、按需记录 header/context、派生并冻结请求,再通过同一个已准备调用发起流式请求。重试不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 压缩(`auto: true` 的 `compaction-basic`)与恢复压缩,并在任一种压缩开启有效文本不同的新序列时将非空提示词文本归并到头部。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。
空头节点且没有生效的后续系统节点表示没有提示词。未生效的空尾节点不提供有效文本,因此重复清除与恢复会话都不会使旧提示词重新生效。重新提供非空文本使用同一准入规则:延续中的具备能力路由可以追加它;不具备能力的路由或新序列则重新填充头节点。清除使用普通的逐节点替换,而非 surface 删除或初次创建空头节点。
@@ -9,8 +9,8 @@
en: e5c984fa32cc5e69
zh: 25d5742b2106d830
/agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision:
en: 8353d57b20ca01c3
zh: e0b74906846d759c
en: 2b047b7d8ffaaeb9
zh: 035e1a99cb0ffe84
/agent-note-pr-artifact-builds-and-aggregate-typescript-checks/alternatives-considered:
en: f61da066f9ddcd62
zh: 816fd6e0063017cd
@@ -20,7 +20,7 @@ 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 two compatibility cases run one job at a time; both x64 Python runtime targets start after the keyless Python SDK job succeeds in CI Python. Four Linux lanes, one compatibility job, three Windows lanes, and the Python SDK job permit at most nine worker jobs initially. Once the SDK job finishes, those four Linux lanes, one compatibility job, and three Windows lanes run beside the two runtime targets, permitting at most ten. Each runtime caller's plan and wheel stages precede its target 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.
Benchmark sample counts stay fixed. The Linux artifact consumers emit artifacts only; the aggregate test and script type checks belong to the master lanes, and lint plus duplication run in their own lane, which compiles package declarations because oxlint's type-aware rules read them, so a red lint no longer costs a rerun of the snapshot and artifact consumers. The two compatibility cases run one job at a time; both x64 Python runtime targets start after the keyless Python SDK job succeeds in CI Python. Five Linux lanes, one compatibility job, three Windows lanes, and the Python SDK job permit at most eleven worker jobs initially. Once the SDK job finishes, those five Linux lanes, one compatibility job, and three Windows lanes run beside the two runtime targets, permitting at most eleven. Each runtime caller's plan and wheel stages precede its target and each use one job. This permits runtime validation to overlap the Node 24.9 build within the eleven-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 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.
@@ -20,7 +20,7 @@ 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;两个 x64 Python runtime target 都在 CI Python 中于 keyless Python SDK job 成功后启动。四个 Linux lane、一个 compatibility job、三个 Windows lane 与 Python SDK job,初始最多占用九个 worker job。SDK job 完成后,这四个 Linux lane、一个 compatibility job 与三个 Windows lane 与两个 runtime target 并行,最多占用十个 job。每个 runtime 调用方的 plan 与 wheel 阶段先于其 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。
benchmark sample 数量保持固定。Linux 产物消费者只产出产物;聚合的 test 与 script 类型检查归 master lane;lint 与 duplication 单独占用一个 lane,该 lane 会先编译包声明(oxlint 的类型感知规则要读它们),因此 lint 变红不再让 snapshot 与 artifact 消费者整体重跑。两个 compatibility case 每次运行一个 job;两个 x64 Python runtime target 都在 CI Python 中于 keyless Python SDK job 成功后启动。五个 Linux lane、一个 compatibility job、三个 Windows lane 与 Python SDK job,初始最多占用十一个 worker job。SDK job 完成后,这五个 Linux lane、一个 compatibility job 与三个 Windows lane 与两个 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 24.9 lazy-search smoke 保留独立的 build 依赖。原来七个 spec 的成对样本保持全部 253 个 assertion outcome,将启动开销比较的 median 从 11.52 秒降至 8.69 秒,且不增加所拥有 descendant process 的峰值。
@@ -9,14 +9,14 @@
en: 212258ada8f174eb
zh: 9a02b100533118fe
/agent-note-interactive-side-sessions-and-merge-back/proposal:
en: 5fa7d2000db6b265
zh: a892b74299e0f984
en: 7f24e61247bf2d84
zh: aaa397f7d4aaf351
/agent-note-interactive-side-sessions-and-merge-back/alternatives-considered:
en: de50cab213a61b91
zh: 85b912193092dfe2
/agent-note-interactive-side-sessions-and-merge-back/acceptance-criteria:
en: 17d0937a1f28b1ac
zh: 9710ab0af3268da1
en: 3dbfd9c97279519f
zh: 719f54d2c0a68e0a
/agent-note-interactive-side-sessions-and-merge-back/risks:
en: 2435d8e2aba0d1c1
zh: 3854c476bea061d4
@@ -12,7 +12,7 @@ A user may want to explore a question from a live session without changing its m
A **side session** is an ordinary live session forked at the source's last completed turn, attached to its own agent, framed as a read-only advisor, and able to **merge back** one condensed note.
- **Fork and attach:** create the child with the parent's balanced completed-turn prefix, `parentSession`, `meta.isSeeded: true`, and the exact sibling `inheritedEventCount`. This composes `ctx.agents.create({ seed, inheritedEventCount, meta })`; it adds no core service or session-store method.
- **Fork and attach:** create the child with the parent's balanced completed-turn prefix, `meta.isSeeded: true`, and the exact sibling `inheritedEventCount`. The caller retains any handback target separately from the copied Session under the [independent-fork decision](../../implemented/architecture/2026-10-23-independent-session-forks.md). This composes `ctx.agents.create({ seed, inheritedEventCount, meta })`; it adds no core service or session-store method.
- **Advisor framing:** inject one plugin-sourced `context/message` after creation that tells the child to explain without mutating or continuing the task. Keeping the system prompt byte-identical preserves the provider prefix cache over inherited history.
- **Merge-back:** ask the child for a length-capped handback, then inject one plugin-sourced `context/message` into the parent. The next parent request sees it at its logged position, preserving replay and [request reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) without a new session event.
- **Presentation:** invocation, session switching, and handback rendering belong to the first client UI. This Agent Note specifies only the client-independent mechanics.
@@ -28,7 +28,7 @@ Rewind productization, session-tree views, a model-facing side-session tool, and
## Acceptance criteria
- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `parentSession`, `isSeeded: true`, the exact `inheritedEventCount`, and a byte-identical system prompt.
- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `isSeeded: true`, the exact `inheritedEventCount`, and a byte-identical system prompt.
- Advisor framing adds exactly one plugin-sourced `context/message` at the head of the child's appended history, rather than changing its system prompt.
- Merge-back adds exactly one length-capped `context/message` with source `plugin: sidechat`; the next parent request and replay see it at the same position.
- Parent and child run concurrently without log or stream cross-talk.
@@ -12,7 +12,7 @@ Status: proposed
**侧会话(side session)**是一个普通的活跃会话,从源会话的最后一个已完成轮次 fork 而来,绑定到自己的 agent(智能体),定位为只读顾问,并能**合并回写**一条精简笔记。
- **Fork 并绑定:**用 parent 的平衡 completed-turn prefix、`parentSession`、`meta.isSeeded: true` 与精确 sibling `inheritedEventCount` 创建 child。这组合 `ctx.agents.create({ seed, inheritedEventCount, meta })`;不新增 core service 或 Session-store method。
- **Fork 并绑定:**用 parent 的平衡 completed-turn prefix、`meta.isSeeded: true` 与精确 sibling `inheritedEventCount` 创建 child。这组合 `ctx.agents.create({ seed, inheritedEventCount, meta })`;不新增 core service 或 Session-store method。 依据[独立 fork 决策](../../implemented/architecture/2026-10-23-independent-session-forks.zh.md),调用方将交回目标保存在所复制 Session 之外。
- **顾问定位:**创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,可在继承的历史上保留提供方的前缀缓存。
- **合并回写:**向子会话请求一条有长度上限的 handback,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求在日志所记录的位置看到该消息,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md),无需新增会话事件。
- **呈现:**调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 Agent Note 仅规定与界面无关的机制。
@@ -28,7 +28,7 @@ Status: proposed
## 验收标准
- Fork 不改变 source Session,创建的 child 带有平衡 completed-turn prefix、`parentSession`、`isSeeded: true`、精确 `inheritedEventCount` 与逐字节相同 system prompt。
- Fork 不改变 source Session,创建的 child 带有平衡 completed-turn prefix、`isSeeded: true`、精确 `inheritedEventCount` 与逐字节相同 system prompt。
- 顾问定位在子会话追加历史的头部恰好添加一条插件来源的 `context/message`,而非修改其系统提示词。
- 合并回写恰好添加一条有长度上限的 `context/message`,来源为 `plugin: sidechat`;父会话的下一次请求与回放在相同位置看到它。
- 父会话与子会话并发运行,日志和流之间无串扰。
+3
View File
@@ -13,3 +13,6 @@ docs/persistence-schema.json linguist-generated=true
docs/persistence-changes/*.schema.json linguist-generated=true
docs/persistence-changes/releases/*.schema.json linguist-generated=true
docs/persistence-changes/historical-formats/*.schema.json linguist-generated=true
# Unified patch blank context lines require their leading space.
patches/*.patch whitespace=-blank-at-eol
@@ -264,6 +264,10 @@ jobs:
- name: Build single-exe
env:
DSH_BUILD_CLIENT_PROFILE: official
# Brotli runs at quality 9 through the vendored pkg patch: the same
# payload measured 16.3 s at quality 9 against 132.8 s at pkg's default
# quality 11, for an archive 10% larger. Which quality each lane uses is
# therefore not a workflow choice.
run: >-
pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
${{ inputs.ci && github.event_name == 'pull_request' && '--build-artifacts' || '' }}
+30 -4
View File
@@ -3,7 +3,9 @@ name: Build PR preview
# Every push to a pull request publishes that pull request's preview to
# Cloudflare Pages under its own branch alias, behind Cloudflare Access. The
# upload carries build products only: the workflow never grants the deployment
# platform access to this repository's sources.
# platform access to this repository's sources. This lane also owns the
# aggregate test and script type checks that the required Linux consumers no
# longer repeat on every pull request.
on:
pull_request:
@@ -32,7 +34,13 @@ env:
jobs:
preview:
runs-on: ubuntu-24.04
# The required outcome is the build, which runs four TypeScript programs
# (Host and Client package compilation, then each face's aggregate test and
# script check) after the Typert pass. Publishing is best effort: transport,
# Access, and comment failures warn instead of failing the check.
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|| 'ubuntu-24.04' }}
name: cloudflare pages preview
steps:
- uses: actions/[email protected]
@@ -70,8 +78,11 @@ jobs:
# apps/web consumes workspace packages as built lib products, and
# build:preview packs the image through the packer's installed bin
# (lib/bin.js), so neither half exists before the artifact build runs.
- name: Build workspace
run: pnpm run build:artifacts
# The required Linux consumers emit artifacts only; the complete build
# here also runs the aggregate test and script checks, which share the
# Typert output and the two package compilations.
- name: Build workspace and check aggregate programs
run: pnpm run build
- name: Build the preview page and pack the VFS image
env:
@@ -88,6 +99,8 @@ jobs:
cp apps/web/dist/preview.html apps/web/dist/index.html
- name: Upload to Cloudflare Pages
id: upload
continue-on-error: true
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -112,6 +125,8 @@ jobs:
# Accept-Encoding is sent because a browser sends it; the assertion is
# about what the platform does with a body that is already compressed.
- name: Verify the protected deployment serves the image
id: verify
continue-on-error: true
env:
CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
@@ -151,6 +166,8 @@ jobs:
# idempotent: a pull request opened before this workflow existed never
# sees an `opened` event, and every later push must not restate the URL.
- name: Comment the preview URL
id: comment
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
@@ -168,3 +185,12 @@ jobs:
"$marker" \
"[Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" \
| gh pr comment "$PR" --body-file -
# Publishing stays outside the required verdict: a Cloudflare, Access, or
# comment failure warns, while a build failure still fails this check.
- name: Report preview publication failures
if: steps.upload.outcome == 'failure' || steps.verify.outcome == 'failure' || steps.comment.outcome == 'failure'
run: |
message='preview publication failed; the build passed, see the upload, verify, and comment steps'
echo "::warning::$message"
echo "$message" >> "$GITHUB_STEP_SUMMARY"
+10 -1
View File
@@ -76,7 +76,7 @@ jobs:
- 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', 'apps/desktop/tests/windows-runner-launcher.ps1')) {
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/scripts/start-windows-packaging-runner.ps1', 'apps/desktop/scripts/windows-runner-network.ps1', 'apps/desktop/scripts/run-windows-runner-network-process.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1', 'apps/desktop/tests/windows-runner-network.ps1', 'apps/desktop/tests/windows-runner-network-process.ps1')) {
$tokens = $null
$parseErrors = $null
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors)
@@ -85,6 +85,15 @@ jobs:
- name: Verify windowless Windows runner launcher
shell: pwsh
run: ./apps/desktop/tests/windows-runner-launcher.ps1
- name: Verify Windows packaging priority inheritance
shell: pwsh
run: ./apps/desktop/tests/windows-packaging-priority.ps1
- name: Verify Windows runner network supervision
shell: pwsh
run: |
./apps/desktop/tests/windows-runner-network.ps1
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
./apps/desktop/tests/windows-runner-network-process.ps1
- name: Run Windows observational gates on built artifacts
id: observational
continue-on-error: true
+72 -3
View File
@@ -202,12 +202,81 @@ jobs:
path: .coverage-times.json
key: coverage-times-Linux-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_LINUX || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }}
# Lint and duplication get their own lane instead of riding the artifact
# consumers: a red lint no longer costs the snapshot and artifact consumers a
# full rerun. The lane compiles package declarations because oxlint's
# type-aware rules read them.
node-24-lint:
if: github.event_name == 'pull_request'
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-8vcpu-ubuntu-2404'
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / lint
env:
# Shared hosts expose host CPUs, not an isolated allocation.
DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
DSH_OXLINT_THREADS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
steps:
# Runner cleanup owns tool caches and fixtures, including cancelled runs.
- name: Use runner-owned temporary storage
run: |
echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"
- uses: actions/[email protected]
with:
persist-credentials: false
- name: Redirect Node compile cache to runner temp
run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
- uses: pnpm/[email protected]
with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/[email protected]
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Configure pnpm store path
id: pnpm-store
run: |
store_root="$HOME/.local/share/pnpm/store"
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
echo "path=$store_path" >> "$GITHUB_OUTPUT"
- uses: actions/cache/[email protected]
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- name: Install (immutable)
run: pnpm install --frozen-lockfile
# oxlint's type-aware rules resolve workspace imports through the package
# declarations, so this lane compiles them without bundling anything.
- name: Compile package declarations
run: pnpm run typecheck
- name: Run lint and duplication
run: pnpm run check:ci:lint:contracts-ready
node-24-bench:
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|| 'ubuntu-24.04' }}
name: node 24 / benchmarks
# Wall-clock budgets need an otherwise idle runner, so this job runs the
# benchmark lane alone instead of joining a concurrent gate aggregate.
# benchmark lane alone instead of joining a concurrent gate aggregate, and
# keeps its own two-file parallelism inside that runner. The self-hosted
# failover pool is shared, so the benchmarks stay on hosted Linux there.
timeout-minutes: 20
steps:
- uses: actions/[email protected]
@@ -503,7 +572,7 @@ jobs:
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'ubuntu-latest' }}
needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat]
needs: [node-24, node-24-coverage, node-24-lint, node-24-bench, node-24-consumers, node-compat]
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
steps:
- name: Fail if any needed job did not succeed
+1 -1
View File
@@ -122,6 +122,6 @@ jobs:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
DEEPSEEK_BASE_URL: https://api.deepseek.com/anthropic
DSH_PRIMARY_RUNTIME: ${{ github.workspace }}/.cache/e2e-office/primary-runtime
DSH_E2E_MAX_WORKERS: 4
DSH_E2E_MAX_WORKERS: 6
DSH_EXAMPLE_MODE: lib
run: pnpm run test:e2e
+14
View File
@@ -9,6 +9,12 @@ on:
options: [test, production]
default: test
required: true
zip_compression_level:
description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强'
type: choice
options: ['3', '7']
default: '3'
required: true
skip_notarization:
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
type: boolean
@@ -57,6 +63,7 @@ jobs:
run:
shell: bash
env:
PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.zip_compression_level }}
PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }}
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
PACKAGE_VERSION_MODE: ${{ inputs.version_mode }}
@@ -95,6 +102,12 @@ jobs:
- uses: actions/[email protected]
with:
node-version: '24'
- name: Configure persistent package caches
env:
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
run: |
echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV"
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
- name: Install locked dependencies
run: pnpm install --frozen-lockfile
- name: Validate preset and resolve build version
@@ -138,5 +151,6 @@ jobs:
name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: ${{ steps.retained.outputs.directory }}/*
compression-level: 0
if-no-files-found: error
retention-days: 14
+16 -6
View File
@@ -30,7 +30,8 @@ jobs:
name: Dependency layout
# Persistent runners accept only trusted, credential-free rehearsals.
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
&& github.repository == 'deepseek-harness/deepseek-harness'
&& github.actor != 'dependabot[bot]'
&& ((github.event_name == 'push' && github.ref == 'refs/heads/master')
@@ -80,11 +81,20 @@ jobs:
- name: Install (immutable)
run: pnpm install --frozen-lockfile
- name: Verify dependency policy
run: pnpm run verify-package-dependencies
- name: Verify npm install layout
run: pnpm run verify-npm-install-layout
# The two verifications read different inputs (manifests versus an npm
# install in a scratch directory), so they run beside each other.
- name: Verify dependency policy and npm install layout
run: |
pnpm run verify-package-dependencies &
dependencies_pid=$!
pnpm run verify-npm-install-layout &
layout_pid=$!
dependencies_status=0
wait "$dependencies_pid" || dependencies_status=$?
layout_status=0
wait "$layout_pid" || layout_status=$?
if (( dependencies_status != 0 )); then exit "$dependencies_status"; fi
exit "$layout_status"
pack:
name: Pack npm tarballs
+84 -2
View File
@@ -42,6 +42,22 @@ on:
type: string
default: ''
required: false
cache_namespace:
description: '缓存测量命名空间:留空使用共享缓存;新名称用于冷缓存,再次使用同名测热缓存,不清除现有缓存'
type: string
default: ''
required: false
build_root:
description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区'
type: string
default: ''
required: false
compression_level:
description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置'
type: choice
options: ['3', '7']
default: '3'
required: true
permissions:
contents: read
@@ -65,6 +81,9 @@ jobs:
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
PACKAGE_SIGNING: ${{ inputs.signing }}
PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }}
PACKAGE_BUILD_ROOT: ${{ inputs.build_root }}
PACKAGE_WINDOWS_COMPRESSION_LEVEL: ${{ inputs.compression_level }}
steps:
- name: Reset packaging checkout
working-directory: ${{ runner.temp }}
@@ -77,6 +96,20 @@ jobs:
}
node -e "const fs = require('node:fs'); fs.rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 }); fs.mkdirSync(process.argv[1])" $workspace
if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' }
- name: Checkout workflow helpers
uses: actions/[email protected]
with:
persist-credentials: false
ref: ${{ github.workflow_sha }}
sparse-checkout: apps/desktop/scripts
- name: Stage workflow helpers
run: |
$helpers = Join-Path $env:RUNNER_TEMP "packaging-helpers-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
New-Item -ItemType Directory -Path $helpers -ErrorAction Stop | Out-Null
foreach ($name in @('windows-packaging-priority.ps1', 'windows-build-workspace.mjs')) {
Copy-Item -LiteralPath (Join-Path 'apps/desktop/scripts' $name) -Destination $helpers -ErrorAction Stop
}
"PACKAGE_CI_HELPERS=$helpers" >> $env:GITHUB_ENV
- uses: actions/[email protected]
id: checkout
with:
@@ -107,16 +140,50 @@ jobs:
- uses: actions/[email protected]
with:
node-version: '24'
- name: Install locked dependencies
- name: Select packaging workspace
id: build_workspace
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
run: |
if (!$env:PACKAGE_BUILD_ROOT) {
"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT
} else {
node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs"
exit $LASTEXITCODE
}
- name: Configure persistent pnpm store
# PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory.
env:
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
run: |
$storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store'
$namespace = $env:PACKAGE_CACHE_NAMESPACE
if ($namespace) {
if ($namespace -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}\z') { throw 'Cache namespace must contain 1-64 lowercase letters, digits or hyphens' }
if ($namespace -match '^(con|prn|aux|nul|com[1-9]|lpt[1-9])$') { throw 'Cache namespace cannot be a Windows device name' }
$storeRoot = Join-Path $storeRoot $namespace
$env:DSH_DESKTOP_PACKAGE_CACHE_DIR = Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace
}
"DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV
"PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
- name: Install locked dependencies
working-directory: ${{ steps.build_workspace.outputs.directory }}
run: |
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm store path
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm install --frozen-lockfile
exit $LASTEXITCODE
- name: Validate preset and resolve build version
id: settings
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
run: node apps/desktop/scripts/prepare-windows-ci.mjs
- name: Check packaging configuration and toolchain
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: |
@@ -125,15 +192,23 @@ jobs:
pnpm @packageArgs
exit $LASTEXITCODE
- name: Build installer and verify packaged runtime
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }}
run: |
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
if ($env:PACKAGE_BUILD_TEMP) {
$env:TEMP = $env:PACKAGE_BUILD_TEMP
$env:TMP = $env:PACKAGE_BUILD_TEMP
}
$packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION)
if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }
pnpm @packageArgs
exit $LASTEXITCODE
- name: Retain complete package outside the checkout
id: retained
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
@@ -144,7 +219,7 @@ jobs:
with:
name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: apps/desktop/.desktop-build/packaging-runs/
path: ${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/
if-no-files-found: warn
retention-days: 14
- name: Retain verified unsigned installer
@@ -152,6 +227,7 @@ jobs:
uses: actions/[email protected]
with:
name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
compression-level: 0
include-hidden-files: true
path: ${{ steps.retained.outputs.directory }}/*
if-no-files-found: error
@@ -161,7 +237,13 @@ jobs:
uses: actions/[email protected]
with:
name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
compression-level: 0
include-hidden-files: true
path: ${{ steps.retained.outputs.directory }}/*
if-no-files-found: error
retention-days: 14
- name: Remove isolated packaging workspace
if: success() && inputs.build_root != ''
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
run: node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove
+1
View File
@@ -112,6 +112,7 @@ External packages installed for runtime use or embedded in distributed Host or b
| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT |
| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT |
| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
| [`micromark-util-decode-string`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-decode-string) | MIT |
| [`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`](https://github.com/broofa/mime) | MIT |
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.2.1-rc.2",
"version": "0.2.1-rc.3",
"publishConfig": {
"access": "public"
},
@@ -107,7 +107,8 @@
"@deepseek-ai/dsh-experimental-session-search": "workspace:*",
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*",
"@deepseek-ai/dsh-cua-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*"
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*",
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/dsh-skill-badge": "workspace:*",
@@ -1,4 +1,4 @@
/** Account catalog diagnostics preserve the shipped ACP profile's JSON-RPC stream. */
/** Account catalog and MCP resource-listing diagnostics keep the shipped ACP profile's stdout JSON-RPC only. */
import { once } from 'node:events'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
@@ -6,10 +6,22 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from '@deepseek-ai/dsh-session-snapshot'
import { expect, it } from 'vitest'
import { cleanupAcpExampleTest } from './cleanup.ts'
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
const binScript = join(repoRoot, 'apps/cli/src/bin.ts')
const tsconfigPath = join(repoRoot, 'tsconfig.json')
/** Parse every stdout line as a JSON-RPC frame; a diagnostic line fails the parse with its leading text. */
function jsonRpcFrames(launched: LaunchedAcpTestAgent): Record<string, unknown>[] {
const frames = launched.rawStdout().trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
for (const frame of frames) expect(frame).toMatchObject({ jsonrpc: '2.0' })
return frames
}
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'
@@ -100,3 +112,48 @@ it('keeps account model-listing diagnostics on stderr during API-key session cre
if (failures.length > 0) throw new AggregateError(failures, 'ACP catalog test cleanup failed')
}
})
it('keeps MCP resource-listing diagnostics off stdout for a tools-only client server', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-stdout-'))
const llm = await startMockLlmServer({
sequence: ['tool_call_success', 'success'],
toolName: 'list_mcp_resources',
toolArguments: JSON.stringify({ server: 'fixture' }),
})
let launched: LaunchedAcpTestAgent | undefined
try {
const configPath = join(cwd, 'mcp.patch.yml')
await writeFile(configPath, JSON.stringify([
{ id: 'llm-deepseek', config: { baseURL: llm.baseURL, models: [{ id: 'fixture-api-key-model' }] } },
{ id: 'acp', config: { provider: 'deepseek-official', model: 'fixture-api-key-model' } },
{ id: 'session-persistence-jsonl', config: { root: join(cwd, 'sessions'), compression: 'none' } },
]) + '\n')
launched = launchAcpTestAgent({
agent: { binScript, configPath, profile: 'acp', tsconfigPath },
cwd,
env: { DEEPSEEK_API_KEY: 'acp-fixture-api-key', DEEPSEEK_BASE_URL: llm.baseURL, DSH_TELEMETRY_DISABLED: '1' },
})
await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
// The fixture server advertises only the tools capability.
const mcpServer = join(repoRoot, 'packages/mcp/mcp-client/tests/fixture-server.ts')
const created = await launched.client.newSession({
cwd, mcpServers: [{ name: 'fixture', command: process.execPath, args: [mcpServer], env: [] }],
})
await expect(launched.client.prompt({
sessionId: created.sessionId,
prompt: [{ type: 'text', text: 'list the fixture resources' }],
})).resolves.toEqual({ stopReason: 'end_turn' })
expect(launched.updates).toContainEqual(expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed' }))
await launched.client.closeSession({ sessionId: created.sessionId })
await launched.close()
expect(launched.child.exitCode).toBe(0)
expect(llm.requests.map(request => request.behavior)).toEqual(['tool_call_success', 'success'])
expect(jsonRpcFrames(launched).length).toBeGreaterThanOrEqual(5)
} finally {
const cleanup = await Promise.allSettled([cleanupAcpExampleTest(launched, cwd), llm.close()])
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 MCP test cleanup failed')
}
})
+1 -1
View File
@@ -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-rc.2",
"version": "0.2.1-rc.3",
"private": true,
"license": "MIT",
"type": "module",
+2 -1
View File
@@ -1,7 +1,7 @@
# Release configuration only; local --unsigned builds ignore this file.
# Copy to .env.macos (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
DSH_DESKTOP_APP_ID=com.deepseek.harness
DSH_DESKTOP_APP_ID=com.deepseek.dsh
DSH_DESKTOP_AUTO_UPDATE_ENV=test
DOWNLOAD_TEST_ORIGIN=
# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator.
@@ -14,6 +14,7 @@ DOWNLOAD_TEST_RELEASE_ID=
# Workspace tarball workers; omit to use 4. Must be a positive integer.
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3
# Optional independent proxy origins; empty values preserve inherited networking.
# Downloads: HTTP/HTTPS proxy for Electron, runtime assets, pnpm and builder downloads.
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
+4 -1
View File
@@ -1,6 +1,6 @@
# Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
# Credential file paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
DSH_DESKTOP_APP_ID=com.deepseek.harness
DSH_DESKTOP_APP_ID=com.deepseek.dsh
DSH_DESKTOP_AUTO_UPDATE_ENV=test
DOWNLOAD_TEST_ORIGIN=
# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator.
@@ -31,6 +31,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN=
# Cache-hit restore and verification workers (1-8). Hardware signing remains serial.
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
# Installer archive compression (0-9); lower values trade size for packaging time.
DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
# Optional upload configuration; packaging does not require these credentials.
# DOWNLOAD_TEST_COS_BUCKET=
# DOWNLOAD_TEST_COS_SECRET_ID=
+2 -2
View File
@@ -15,8 +15,8 @@
en: 127ea90553cb6af7
zh: 7b1c5aec8558c684
/deepseek-harness-desktop/application-overview:
en: 4ef114678c4e4cc3
zh: 2a65eab776634cbc
en: b57525e17b9638ab
zh: 274fe8344f7c2202
/deepseek-harness-desktop/terminal-command:
en: 5f2d25a1d8472647
zh: 2acb5c5d7d1917e2
+1 -1
View File
@@ -43,7 +43,7 @@ The first application-menu command, **About DeepSeek Harness**, opens Electron's
Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`. Build-time production installation, the installed `dsh` CLI, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node.
Desktop ships one pnpm distribution at `resources/runtime/primary-runtime/dependencies/pnpm`, without the native packages of other targets. Build-time production installation, the installed `dsh` CLI, Creator and the Web Plugin Manager execute its `bin/pnpm.mjs` with Electron Node mode; package operations do not require pnpm on PATH. The private Node launcher environment applies only to package operations. Agent workspace dependencies copy the same self-contained primary-runtime payload into the Harness home and execute pnpm with its standalone Node.
Embedded Platform documents use a persistent WebContentsView partition keyed by a hash of the Platform origin and stable account ID. localStorage preferences, including dismissed notices, survive view closure and application restart; different accounts and origins use separate storage. The Host supplies the account ID from its latest successful profile read; while no ID is known, the document opens in a disposable partition that keeps no preference across opens. Opening a persistent partition clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads; a disposable partition clears all of its storage. Closing a view destroys the document, removes its request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install waits for it before the installer takes over the exit. A failed cleanup rejects that open and blocks the same account until a later cleanup succeeds; other accounts keep opening. Sign-out destroys the document but retains account-scoped preferences for the next sign-in. A document already open in a disposable partition stays mounted when the account ID arrives for the same credential; the next open uses the account-scoped partition. The [storage decision](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md) describes the retention policy. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar.
+1 -1
View File
@@ -43,7 +43,7 @@ Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过
Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包。构建时的生产依赖安装、安装版 `dsh` CLI、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。
Desktop 在 `resources/runtime/primary-runtime/dependencies/pnpm` 中携带一份 pnpm 分发包,不含其他目标平台的原生包。构建时的生产依赖安装、安装版 `dsh` CLI、Creator 和 Web 插件管理器通过 Electron Node 模式执行其中的 `bin/pnpm.mjs`;包操作不要求 PATH 上存在 pnpm。私有 Node 启动器环境仅用于包操作。agent(智能体)的工作区依赖将同一份完整 primary-runtime 复制到 Harness 主目录,并使用其中的独立 Node 执行 pnpm。
Platform 内嵌文档使用持久化 WebContentsView 分区,分区名由 Platform 来源和稳定账号 ID 的哈希决定。localStorage 中的页面偏好(包括已关闭的通知)在关闭视图和重启应用后保留;不同账号和来源使用独立存储。账号 ID 来自 Host 最近一次成功的资料读取;尚无该 ID 时,文档在一次性分区中打开,该分区不跨次保留偏好。打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态;一次性分区则清理其全部存储。关闭视图会销毁文档、移除请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号打开;其他账号不受影响。退出登录会销毁文档,但保留账号偏好供下次登录使用。同一凭证下账号 ID 迟到时,已以一次性分区打开的文档保持挂载;下次打开使用账号分区。[存储决策](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md)说明保留策略。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。
@@ -1,8 +1,9 @@
# Copy to production.env and configure the production service before selecting this preset.
DSH_DESKTOP_APP_ID=com.deepseek.harness
DSH_DESKTOP_APP_ID=com.deepseek.dsh
DSH_DESKTOP_AUTO_UPDATE_ENV=production
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY=
# Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage.
@@ -1,5 +1,5 @@
# Copy to test.env; paths in secret configuration resolve beside macos-signing.env.
DSH_DESKTOP_APP_ID=com.deepseek.harness
DSH_DESKTOP_APP_ID=com.deepseek.dsh
DSH_DESKTOP_AUTO_UPDATE_ENV=test
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}'
@@ -7,6 +7,7 @@ DOWNLOAD_TEST_ORIGIN=
# Generate a 32-character lowercase hexadecimal release ID; retain it for upgrade testing.
DOWNLOAD_TEST_RELEASE_ID=
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
# Temporarily changes the active network service's system HTTP/HTTPS proxies.
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY=
@@ -1,7 +1,9 @@
# Copy to production.env in the same machine configuration directory and fill in the values.
DSH_DESKTOP_APP_ID=com.deepseek.harness
DSH_DESKTOP_APP_ID=com.deepseek.dsh
DSH_DESKTOP_AUTO_UPDATE_ENV=production
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}'
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
# Installer archive compression (0-9).
DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
@@ -1,6 +1,6 @@
# Copy to test.env in the same machine configuration directory and fill in the values.
# Public test configuration. No PIN or upload credentials belong here.
DSH_DESKTOP_APP_ID=com.deepseek.harness
DSH_DESKTOP_APP_ID=com.deepseek.dsh
DSH_DESKTOP_AUTO_UPDATE_ENV=test
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
# Replace login.example.com with the real login origin before packaging.
@@ -11,3 +11,5 @@ DOWNLOAD_TEST_RELEASE_ID=
# Optional absolute private cache directory. Omit to retain the account default cache.
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
# Installer archive compression (0-9).
DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
+1 -1
View File
@@ -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-rc.2",
"version": "0.2.1-rc.3",
"private": true,
"license": "MIT",
"author": {
+16 -16
View File
@@ -15,8 +15,8 @@
en: a6203dc2e690c7e1
zh: 07b516549b8e3328
/desktop-packaging/choose-a-build-path:
en: 17c6bcdefa05a8f8
zh: 4ee18f832276144d
en: 0b35c7471c09b27a
zh: cc4a382b99fc3210
/desktop-packaging/release-versions:
en: 3f38ab26ee195b39
zh: f00eddafa8f3b9ca
@@ -24,20 +24,20 @@
en: 5b4f0f80a117f202
zh: ff05be196c181087
/desktop-packaging/windows-actions-packaging:
en: e1b101593f6f755e
zh: 121f225de399de1e
en: 364223725374d9ac
zh: c4d4cc58d7cbafbd
/desktop-packaging/macos-self-hosted-packaging:
en: a3af9523fdc60036
zh: 5e18d4740e9b7f57
en: bb1dcdfef3f3a113
zh: 9c151800a61e2505
/desktop-packaging/unsigned-windows-test-installer:
en: 6843a0fdc03ead58
zh: 957f92fc8126e23e
en: f429bc0c280e2dfa
zh: 4adbcc9e906bdf80
/desktop-packaging/local-macos-dmg-without-release-signing:
en: 2feaa0412247d81f
zh: 461dde8e13fd4734
/desktop-packaging/macos-release-signing-and-diagnostics:
en: f4a2b14da34ba883
zh: 6292f3a7b4038213
en: 29fa6bd8919f3ec0
zh: 9e31e037fa3d9378
/desktop-packaging/directory-builds-and-preparation:
en: 5860c306375ddd63
zh: a76e3cdbbe25c79e
@@ -45,14 +45,14 @@
en: b42f103358d0dfd7
zh: a534164ad9eb00da
/desktop-packaging/runtime-file-selection-and-installation-checks:
en: bfd89e1d139c5729
zh: 5d6cced5462ba13e
en: 83e1075417039410
zh: 3088a7ae0a2cc3c5
/desktop-packaging/desktop-runtime-preparation:
en: a7aa3066e649ffe8
zh: 4334637910058c09
en: ab73b67d899e611d
zh: d77b0525c6b81e91
/desktop-packaging/installation-and-release-verification:
en: eb35eb973b796643
zh: c1c52311632b7296
/desktop-packaging/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
en: 691194744993dfaa
zh: e5798faf99546ff8
+24 -5
View File
@@ -32,6 +32,8 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep
Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation.
Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory for reuse across checkouts; see [cache configuration and lifecycle](#desktop-runtime-preparation).
<a id="release-versions"></a>
## Release versions
@@ -113,18 +115,26 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a
Open the completed run in Actions and download its Artifacts. `windows-x64-signed-<version>-<run_id>-<run_attempt>` and `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` contain the corresponding EXE; `windows-package-logs-<run_id>-<run_attempt>` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication.
For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package-<run-id>-<attempt>/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing.
For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory.
The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats.
Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression.
<a id="macos-actions"></a>
## macOS self-hosted packaging
The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it.
The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG.
Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior.
Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing).
The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets/<target>/unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds.
ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets/<target>/unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds.
Install the user service from the checkout as the same account that registered the runner, without `sudo`:
@@ -146,7 +156,7 @@ On Windows x64, use the complete unsigned packaging command for local installati
pnpm run package:desktop:win:x64:unsigned
```
The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements.
The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell 7 (`pwsh`), tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements.
Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths.
@@ -179,11 +189,12 @@ Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory un
App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log.
Mac packaging reads three tuning fields from `.env.macos`:
Mac packaging reads these tuning fields from `.env.macos`:
| Setting | Default | Scope |
|---|---|---|
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. |
| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | Final update ZIP compression, integer `0`–`9`; lower levels trade larger downloads for shorter builds. App notarization archives and DMG compression are unchanged. |
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. |
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. |
@@ -254,18 +265,26 @@ When extraction fails, the installer writes a report with the 7-Zip result and i
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.
`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Mac ZIP workflow overrides must be `3` or `7`; an empty value is invalid even on macOS. Any defined Mac ZIP workflow override, including an empty value, rejects Windows; nonempty Windows workflow overrides reject macOS. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset.
NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree.
<a id="desktop-runtime-preparation"></a>
## Desktop runtime preparation
Windows prepares Electron and primary-runtime concurrently; macOS prepares them serially. CLI preparation and `versions.json` publication require both payloads to succeed. If either Windows payload fails, preparation waits for the other to settle before reporting failure and publishes neither CLI resources nor versions. Nested preparation timings overlap and must not be added together.
`DSH_DESKTOP_PACKAGE_CACHE_DIR` in the packaging process environment selects a nonempty absolute directory for shared `downloads` and `pnpm-store` caches; omission uses `.desktop-build`. Temporary installation projects, lockfiles and configuration remain separate per run. Runtime installation verifies store integrity and copies package files so later signing cannot modify cached bytes; cleanup retains these caches. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; Windows repository dependency installation uses a separate `.pnpm-store` at the workspace drive root. Both installation logs identify their store and report package reuse. A fresh runtime lockfile still resolves dependencies on every run. Share writable caches only among trusted build jobs; integrity checks do not isolate untrusted writers. Clean caches only when no job is using them. Operators manage cache disk usage; no automatic eviction runs.
Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures.
Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration.
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks.
Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload.
An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine.
@@ -280,4 +299,4 @@ Keep the generated installer, its version and packaging records together. Review
## Dev Note
None.
XXX: Evaluate disk-space monitoring, a maintenance entry point and automatic eviction if disk shortages, sustained cache growth or frequent manual cleanup occur. Any automated cleanup would need coordination with every cache user.
+24 -5
View File
@@ -32,6 +32,8 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包
安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。
将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录以跨 checkout 复用;详见[缓存配置与生命周期](#desktop-runtime-preparation)。
<a id="release-versions"></a>
## 发布版本
@@ -113,18 +115,26 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed-<version>-<run_id>-<run_attempt>` 与 `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` 包含对应 EXE;`windows-package-logs-<run_id>-<run_attempt>` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。
测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package-<run-id>-<attempt>/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。
测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。
依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。
安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。
<a id="macos-actions"></a>
## macOS 自托管打包
`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。
`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。
运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。
工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets/<target>/unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。
无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets/<target>/unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。
在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`:
@@ -146,7 +156,7 @@ bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner"
pnpm run package:desktop:win:x64:unsigned
```
该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。
该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell 7(`pwsh`)、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。
先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。
@@ -179,11 +189,12 @@ Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-
App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。
Mac 打包从 `.env.macos` 读取三个调优字段:
Mac 打包从 `.env.macos` 读取以下调优字段:
| 设置 | 默认值 | 作用范围 |
|---|---|---|
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 |
| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | 最终更新 ZIP 的压缩等级,整数 `0`–`9`;较低等级以更大的下载体积换取更短的构建时间。App 公证归档和 DMG 压缩不变。 |
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 |
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 |
@@ -254,18 +265,26 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运
Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。
Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。Mac ZIP workflow 覆盖值必须为 `3` 或 `7`;空值在 macOS 上也非法。任何已定义的 Mac ZIP workflow 覆盖值(包括空值)均拒绝用于 Windows;非空的 Windows workflow 覆盖值拒绝用于 macOS。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。
NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。
<a id="desktop-runtime-preparation"></a>
## Desktop 运行时准备
Windows 并行准备 Electron 和 primary-runtime;macOS 串行准备二者。只有两份运行时资源均成功,才会准备 CLI 并写入 `versions.json`。任一 Windows 运行时准备失败时,流程会等待另一路结束后再报告失败,且不写入 CLI 资源或版本记录。嵌套准备阶段的计时存在重叠,不能直接相加。
打包进程环境中的 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 为共享的 `downloads` 和 `pnpm-store` 缓存指定非空绝对目录;省略时使用 `.desktop-build`。临时安装项目、lockfile 和配置仍逐次独立创建。运行时安装校验 store 完整性并复制包文件,防止后续签名修改缓存字节;清理时保留这些缓存。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;Windows 仓库依赖安装使用工作区盘根目录下独立的 `.pnpm-store`。两次安装日志均标明各自的 store 并报告包复用情况。每次运行仍通过新的运行时 lockfile 解析依赖。可写缓存仅供可信构建任务共享;完整性校验不能隔离不可信的写入者。仅在没有任务使用缓存时清理。缓存磁盘用量由操作者管理,不自动淘汰。
Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。
Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。
macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。
未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
@@ -280,4 +299,4 @@ Windows 以不带版本后缀的产品名称显示已安装应用,版本保留
## 开发备注
无。
XXX:出现磁盘空间不足、缓存持续增长或频繁人工清理时,评估空间监测、维护入口和自动淘汰。任何自动清理方案都需要与所有使用缓存的任务协调。
@@ -1,6 +1,6 @@
import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs'
/** Mutable target directories plus the shared immutable download cache. */
/** Mutable target directories plus shared download and package caches. */
export interface DesktopTargetBuildPaths {
readonly root: string
readonly artifacts: string
@@ -15,6 +15,7 @@ export interface DesktopTargetBuildPaths {
readonly packedVendor: string
readonly packedLandlock: string
readonly downloads: string
readonly pnpmStore: string
}
/**
@@ -33,9 +34,10 @@ export function resolveDesktopBuildTarget(
/**
* Return the mutable preparation and artifact directories owned by one release target.
* @param target - Supported Desktop target name.
* @returns Target paths plus the shared immutable download cache.
* @param environment - Packaging environment with an optional absolute cache directory.
* @returns Target paths plus shared download and package caches.
*/
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget, environment?: NodeJS.ProcessEnv): DesktopTargetBuildPaths
/**
* Return the platform and architecture of the payload one release target prepares.
+12 -6
View File
@@ -1,6 +1,6 @@
/** Resolve build-owned Desktop paths without sharing mutable state across release targets. */
/** Resolve target-owned Desktop outputs and shared package caches. */
import { join, resolve } from 'node:path'
import { isAbsolute, join, resolve } from 'node:path'
const APP_ROOT = resolve(import.meta.dirname, '..')
const BUILD_ROOT = join(APP_ROOT, '.desktop-build')
@@ -38,10 +38,15 @@ function assertSupportedTarget(target) {
/**
* Return the mutable preparation and artifact directories owned by one release target.
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
* @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
* @param {NodeJS.ProcessEnv} environment - Packaging environment with an optional absolute cache directory.
* @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string, pnpmStore: string }} Target paths plus shared download and package caches.
*/
export function desktopTargetBuildPaths(target) {
export function desktopTargetBuildPaths(target, environment = process.env) {
assertSupportedTarget(target)
const cacheRoot = environment.DSH_DESKTOP_PACKAGE_CACHE_DIR ?? BUILD_ROOT
if (!cacheRoot.trim() || !isAbsolute(cacheRoot) || cacheRoot.includes('\0')) {
throw new Error('desktop package: DSH_DESKTOP_PACKAGE_CACHE_DIR must be a nonempty absolute directory')
}
const root = join(BUILD_ROOT, 'targets', target)
const packed = join(root, 'packed')
return {
@@ -57,7 +62,8 @@ export function desktopTargetBuildPaths(target) {
packedDsh: join(packed, 'dsh'),
packedVendor: join(packed, 'vendor'),
packedLandlock: join(packed, 'landlock'),
downloads: join(BUILD_ROOT, 'downloads'),
downloads: join(cacheRoot, 'downloads'),
pnpmStore: join(cacheRoot, 'pnpm-store'),
}
}
@@ -87,7 +93,7 @@ export function resolveDesktopTargetBuildPaths(
hostPlatform = process.platform,
hostArch = process.arch,
) {
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env)
}
/**
@@ -10,6 +10,9 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N
/**
* Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
* PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID.
* PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid.
* Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS.
* PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting.
* @param platform Target platform.
* @param environment Parent environment, retained only for unrelated build tools.
* @param appRoot Desktop application directory; relative credential paths resolve here.
@@ -12,11 +12,12 @@ import { validateMacOSSignatureCacheDirectory } from './macos-signature-cache-di
import { resolveMacOSPackageSettings } from './macos-package-settings.mjs'
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
@@ -35,7 +36,10 @@ export function localMacOSPackageEnvironment(environment = process.env) {
/**
* Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
* PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid.
* Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS.
* PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID.
* PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting.
* @param {'win32' | 'darwin'} platform Target platform.
* @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools.
* @param {string} appRoot Desktop application directory; relative credential paths resolve here.
@@ -43,6 +47,17 @@ export function localMacOSPackageEnvironment(environment = process.env) {
* @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings.
*/
export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) {
const compression = environment.PACKAGE_WINDOWS_COMPRESSION_LEVEL
if (compression !== undefined && compression !== '') {
if (platform !== 'win32' || !['3', '7'].includes(compression)) {
throw new Error('desktop package: PACKAGE_WINDOWS_COMPRESSION_LEVEL requires Windows and a choice of 3 or 7')
}
}
const zipCompression = environment.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL
if (zipCompression !== undefined) {
if (platform !== 'darwin') throw new Error('desktop package: PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS')
if (!['3', '7'].includes(zipCompression)) throw new Error('desktop package: workflow ZIP compression must be 3 or 7')
}
const preset = environment.DSH_PACKAGING_PRESET
let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
if (preset !== undefined) {
@@ -53,8 +68,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
}
const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
const publicPlatformSetting = platform === 'win32'
? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY)$/u
: /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u
? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
: /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u
const settings = readSettings(path, name => preset === undefined
? SHARED_SETTING.test(name) || platformSetting.test(name)
: /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name) || publicPlatformSetting.test(name))
@@ -68,6 +83,10 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
: MACOS_SETTING.test(name) && !publicPlatformSetting.test(name)))
}
}
if (compression !== undefined && compression !== '') settings.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL = compression
if (zipCompression !== undefined) {
settings.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL = zipCompression
}
const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim()
if (releaseId) {
if (preset !== 'test') throw new Error('desktop package: test release ID override requires the test preset')
@@ -134,6 +153,7 @@ function requireReadableFile(environment, name) {
* @returns {void}
*/
export function validateDesktopPackageEnvironment(environment, target, options = {}) {
resolveDesktopTargetBuildPaths(environment, target.platform, target.arch)
resolveDesktopAppId(environment)
resolveNpmRegistry(environment)
if (target.platform !== 'darwin' || !options.unsigned) resolveDesktopPolicyEnvironment(environment)
@@ -70,7 +70,7 @@ export function createElectronBuilderConfig(
if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller()
const macOSSigning = packagesMacOS && !unsigned ? resolveMacOSSigningEnvironment(env) : undefined
if (packagesMacOS && !unsigned && !skipNotarization) resolveMacOSNotarizationEnvironment(env)
const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env)
const output = desktopArtifactOutput(buildPaths, { unsigned, skipNotarization })
let primaryRuntimeDestination
let dshDestination
@@ -3,6 +3,7 @@
/** Validated settings shared by preflight and stage execution. */
export interface MacOSPackageSettings {
readonly packConcurrency: number
readonly zipCompressionLevel: number
readonly downloadProxy?: string
readonly notarizationProxy?: string
}
@@ -15,14 +15,19 @@ function proxy(environment, name, protocols) {
/**
* Validate tuning before any build or network operation.
* @param {NodeJS.ProcessEnv} environment File-owned packaging settings.
* @returns {{packConcurrency: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking.
* @returns {{packConcurrency: number, zipCompressionLevel: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking.
*/
export function resolveMacOSPackageSettings(environment) {
const concurrency = environment.DSH_DESKTOP_MACOS_PACK_CONCURRENCY ?? '4'
if (!/^[1-9]\d*$/u.test(concurrency) || !Number.isSafeInteger(Number(concurrency))) {
throw new Error('desktop package: DSH_DESKTOP_MACOS_PACK_CONCURRENCY must be a positive integer')
}
const compression = environment.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL ?? '3'
if (!/^[0-9]$/u.test(compression)) {
throw new Error('desktop package: DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL must be an integer from 0 to 9')
}
return {
zipCompressionLevel: Number(compression),
packConcurrency: Number(concurrency),
downloadProxy: proxy(environment, 'DSH_DESKTOP_MACOS_DOWNLOAD_PROXY', ['http:', 'https:']),
notarizationProxy: proxy(environment, 'DSH_DESKTOP_MACOS_NOTARIZATION_PROXY', ['http:']),
+63 -32
View File
@@ -328,7 +328,7 @@ async function resolveRequestedBuildVersion(
const requested = invocation.requestedBuildVersion
if (requested === undefined) return productVersion
if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion)
const paths = desktopTargetBuildPaths(invocation.target.name)
const paths = desktopTargetBuildPaths(invocation.target.name, environment)
return suggestDesktopBuildVersion({
productVersion, target: invocation.target.name,
// Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts.
@@ -370,7 +370,7 @@ async function main(): Promise<void> {
version: buildVersion, productVersion, node: process.version,
commit: packaged.commit,
dirty: packaged.dirty,
}, { parallel: target.platform === 'darwin', secrets })
}, { parallel: true, secrets })
console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
const previousDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = run.directory
@@ -380,7 +380,7 @@ async function main(): Promise<void> {
await packagingStep(run.directory, 'toolchain', () => requireDesktopToolchain(target.platform, environment), secrets)
if (target.platform === 'darwin') {
const settings = resolveMacOSPackageSettings(environment)
recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency,
recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency, zipCompressionLevel: settings.zipCompressionLevel,
downloadProxyConfigured: settings.downloadProxy !== undefined,
notarizationProxyConfigured: settings.notarizationProxy !== undefined })
await packagingStep(run.directory, 'macos-package', () => invocation.unsigned
@@ -400,7 +400,7 @@ async function main(): Promise<void> {
run.finish(success)
}
if (success && target.platform === 'darwin' && !invocation.directory && !invocation.prepareOnly) {
const paths = desktopTargetBuildPaths(target.name)
const paths = desktopTargetBuildPaths(target.name, environment)
const output = desktopArtifactOutput(paths, invocation)
const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${output.suffix}.dmg`
console.log(`DMG: ${join(output.directory, filename)}`)
@@ -426,7 +426,7 @@ export async function packageTarget(
const mac = target.platform === 'darwin' ? resolveMacOSPackageSettings(environment) : undefined
const packConcurrency = mac?.packConcurrency ?? resolveWindowsPackageSettings(environment).packConcurrency
const packArguments = ['--concurrency', String(packConcurrency)]
const buildPaths = desktopTargetBuildPaths(target.name)
const buildPaths = desktopTargetBuildPaths(target.name, environment)
const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
if (!invocation.prepareOnly && !invocation.unsigned && !invocation.skipNotarization) {
rmSync(releaseRecordPath, { force: true })
@@ -443,6 +443,9 @@ export async function packageTarget(
}
const downloadEnv = macOSDownloadEnvironment(targetEnv, mac?.downloadProxy)
const electronBuilderEnv = desktopElectronBuilderEnvironment(downloadEnv, invocation.unsigned)
if (target.platform === 'win32') {
electronBuilderEnv.ELECTRON_BUILDER_COMPRESSION_LEVEL = String(resolveWindowsPackageSettings(environment).compressionLevel)
}
for (const name of WINDOWS_SIGNING_ENV_NAMES) {
if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
}
@@ -476,36 +479,56 @@ export async function packageTarget(
})
}
await execute(invocation.unsigned ? ['run', 'build', '--profile', 'local'] : ['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
await execute([
...(invocation.unsigned
? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts']
: ['run', 'release:pack', '--family', 'dsh']),
'--out', buildPaths.packedDsh, ...packArguments,
], buildEnv, REPOSITORY_ROOT)
await execute([
'--dir',
'apps/desktop-host',
'pack',
'--pack-destination',
buildPaths.packedDsh,
], buildEnv, REPOSITORY_ROOT)
await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT)
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
mkdirSync(buildPaths.packedLandlock, { recursive: true })
await execute(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
await execute([
'--dir',
'native/system/packages/entry',
'pack',
'--pack-destination',
buildPaths.packedLandlock,
], buildEnv, REPOSITORY_ROOT)
await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv)
let preparationFailure: { error: unknown } | undefined
const executePreparation = async (args: readonly string[], env: NodeJS.ProcessEnv, cwd?: string): Promise<void> => {
if (preparationFailure !== undefined) throw preparationFailure.error
try { await execute(args, env, cwd) }
catch (error) { preparationFailure ??= { error }; throw error }
}
const pack = async (): Promise<void> => {
await executePreparation([
...(invocation.unsigned
? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts']
: ['run', 'release:pack', '--family', 'dsh']),
'--out', buildPaths.packedDsh, ...packArguments,
], buildEnv, REPOSITORY_ROOT)
await executePreparation([
'--dir',
'apps/desktop-host',
'pack',
'--pack-destination',
buildPaths.packedDsh,
], buildEnv, REPOSITORY_ROOT)
await executePreparation(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT)
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
mkdirSync(buildPaths.packedLandlock, { recursive: true })
await executePreparation(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
await executePreparation([
'--dir',
'native/system/packages/entry',
'pack',
'--pack-destination',
buildPaths.packedLandlock,
], buildEnv, REPOSITORY_ROOT)
}
const prepareRuntime = () => executePreparation(
['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv)
if (target.platform === 'win32') {
const results = await Promise.allSettled([pack(), prepareRuntime()])
for (const result of results) if (result.status === 'rejected') throw result.reason
} else {
await pack()
await prepareRuntime()
}
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv)
await execute(['run', 'prepare:packages'], targetEnv)
await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv)
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv)
if (invocation.prepareOnly) return
const buildMacArtifact = (artifact: DesktopPrepackagedArtifact) => execute(
desktopElectronBuilderArguments(target, false, artifact), artifact.format === 'zip'
? { ...electronBuilderEnv, ELECTRON_BUILDER_COMPRESSION_LEVEL: String(mac?.zipCompressionLevel) }
: electronBuilderEnv)
if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization && !invocation.directory) {
await execute([
...desktopElectronBuilderArguments(target, true),
@@ -518,7 +541,7 @@ export async function packageTarget(
version: resolveDesktopBuildVersion(environment, packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')),
artifactsRoot: buildPaths.artifacts,
environment: electronBuilderEnv,
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent)
}, buildMacArtifact), undefined, undefined, proxyEvent)
} else if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization) {
await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv)
await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv)
@@ -526,7 +549,15 @@ export async function packageTarget(
await withMacOSNotarizationProxy(mac?.notarizationProxy,
() => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent)
} else {
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
if (target.platform === 'darwin' && invocation.skipNotarization && !invocation.directory) {
await execute(desktopElectronBuilderArguments(target, true), electronBuilderEnv)
const appPath = join(artifactsRoot, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
for (const format of ['dmg', 'zip'] as const) {
await buildMacArtifact({ format, appPath, output: artifactsRoot })
}
} else {
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
}
await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts',
...(invocation.unsigned ? ['--unsigned'] : []), ...(invocation.skipNotarization ? ['--skip-notarization'] : [])], targetEnv)
}
+13 -4
View File
@@ -3,6 +3,7 @@
import { resolveMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs'
import { packagingStep } from './packaging-step.mjs'
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join, relative, resolve } from 'node:path'
@@ -22,7 +23,7 @@ import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts'
import { prepareRuntimePatches, verifyRuntimePatches } from './prepare-runtime-patches.ts'
import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts'
import { writeDesktopRuntime, writeDesktopRuntimeAsync, verifyDesktopRuntime } from '../src/runtime-tree.ts'
import {
resolveDesktopAppId,
resolveMacOSSigningEnvironment,
@@ -39,7 +40,7 @@ const APP_ROOT = resolve(import.meta.dirname, '..')
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh
const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-'))
const STORE_ROOT = join(BUILD_ROOT, 'store')
const STORE_ROOT = BUILD_PATHS.pnpmStore
const RUNTIME_ROOT = BUILD_PATHS.runtime
const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm
const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
@@ -82,6 +83,8 @@ function runPnpm(args: readonly string[]): Promise<void> {
PNPM,
`--config.registry=${registry}`,
`--config.store-dir=${STORE_ROOT}`,
'--config.verify-store-integrity=true',
'--config.package-import-method=copy',
'--config.enable-global-virtual-store=false',
`--config.userconfig=${userConfig}`,
command,
@@ -113,6 +116,7 @@ function runPnpm(args: readonly string[]): Promise<void> {
async function main(): Promise<void> {
try {
console.log(`desktop runtime: pnpm store ${STORE_ROOT}`)
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:reset', async () => {
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
@@ -127,8 +131,10 @@ async function main(): Promise<void> {
})
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only']))
verifyRuntimePatches(BUILD_ROOT, patches)
const lockfile = readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8')
console.log(`desktop runtime: lockfile sha256 ${createHash('sha256').update(lockfile).digest('hex')}`)
verifyDesktopCoreLockfile(
readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'),
lockfile,
readDesktopCorePackageSet(BUILD_ROOT, release.version),
)
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']))
@@ -164,7 +170,10 @@ async function main(): Promise<void> {
}
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => {
const writeRuntime = target.platform === 'win32' ? writeDesktopRuntimeAsync : writeDesktopRuntime
return writeRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
})
const descriptor = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:verify-before-smoke', () => verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target))
if (!process.argv.includes('--defer-runtime-smoke')) {
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:smoke', () => smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor))
+11 -2
View File
@@ -53,9 +53,18 @@ async function main(): Promise<void> {
const target = desktopTargetPlatform(resolveDesktopBuildTarget())
rmSync(RUNTIME_ROOT, { recursive: true, force: true })
mkdirSync(RUNTIME_ROOT, { recursive: true })
const nodeVersion = await prepareElectron(target)
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
const preparePrimary = () => packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
() => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] }))
let nodeVersion: string
if (target.platform === 'win32') {
const [electron, primary] = await Promise.allSettled([prepareElectron(target), preparePrimary()])
if (electron.status === 'rejected') throw electron.reason
if (primary.status === 'rejected') throw primary.reason
nodeVersion = electron.value
} else {
nodeVersion = await prepareElectron(target)
await preparePrimary()
}
const { pnpm } = await readPrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))
if (pnpm === undefined) throw new Error('desktop runtime: primary-runtime manifest has no pnpm version')
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', async () => { prepareCli(target) })
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process'
import { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs'
export { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
const appRoot = resolve(import.meta.dirname, '..')
@@ -22,6 +23,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
}
}
validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' })
console.log(`Windows installer compression level: ${resolveWindowsPackageSettings(environment).compressionLevel}`)
if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
@@ -0,0 +1,138 @@
# Own one operator-configured network process until the registered runner exits.
param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [Parameter(Mandatory=$true)][string]$NetworkRoot, [Parameter(Mandatory=$true)][string]$ReadinessScript, [Parameter(Mandatory=$true)][string]$StartScript, [switch]$CheckOnly)
$ErrorActionPreference = 'Stop'
$network = $NetworkRoot
$lock = $null
$process = $null
$started = $false
$runner = $null
$runnerStarted = $false
$restarts = 0
$recordPath = Join-Path $network 'process.json'
$code = 1
try {
foreach ($directory in @($RunnerRoot,$ConfigRoot,$NetworkRoot)) {
if ($directory -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $directory -PathType Container)) { throw 'An existing absolute local directory is required.' }
}
foreach ($script in @($ReadinessScript,$StartScript)) {
if ($script -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $script -PathType Leaf)) { throw 'An existing absolute local script is required.' }
}
$lock = [IO.File]::Open((Join-Path $network 'supervisor.lock'), 'OpenOrCreate', 'ReadWrite', 'None')
$launch = Get-Content (Join-Path $network 'launch.json') -Raw | ConvertFrom-Json -AsHashtable
if ($launch -isnot [Collections.IDictionary] -or $launch.Count -ne 3 -or @($launch.Keys | Where-Object { $_ -cnotin @('executable','arguments','restartDelaySeconds') }).Count) { throw 'Invalid network launch configuration.' }
if (($launch.restartDelaySeconds -isnot [long] -and $launch.restartDelaySeconds -isnot [int]) -or $launch.restartDelaySeconds -lt 1 -or $launch.restartDelaySeconds -gt 3600) { throw 'Invalid network restart interval.' }
if ($launch.executable -isnot [string] -or $launch.arguments -isnot [array] -or @($launch.arguments | Where-Object { $_ -isnot [string] -or $_.Contains([char]0) }).Count) { throw 'Invalid network executable or arguments.' }
if (-not [IO.Path]::IsPathFullyQualified($launch.executable) -or -not (Test-Path -LiteralPath $launch.executable -PathType Leaf)) { throw 'Network executable unavailable.' }
$launch.executable = [IO.Path]::GetFullPath($launch.executable)
if (Test-Path -LiteralPath $recordPath) {
$record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json
if ($record.pid -isnot [long] -and $record.pid -isnot [int]) { throw 'Invalid network process record.' }
if ($record.pid -le 0 -or $record.started -isnot [long] -or $record.started -le 0) { throw 'Invalid network process record.' }
$stale = Get-Process -Id $record.pid -ErrorAction SilentlyContinue
if ($stale) {
try {
if (-not $stale.StartTime) { throw 'Owned network process identity unavailable.' }
if ($stale.StartTime.ToUniversalTime().Ticks -eq $record.started) {
if ($stale.Path -ne $launch.executable) { throw 'Owned network process identity unavailable.' }
$stale.Kill($true)
if (-not $stale.WaitForExit(10000)) { throw 'Owned network process did not stop.' }
}
} finally { $stale.Dispose() }
}
Remove-Item -LiteralPath $recordPath
}
$info = [Diagnostics.ProcessStartInfo]::new($launch.executable)
$info.UseShellExecute = $false
$info.CreateNoWindow = $true
$info.WorkingDirectory = $network
foreach ($name in @('HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy')) { $null = $info.Environment.Remove($name) }
# Provider output can include private routes; it must never enter runner logs.
$info.RedirectStandardOutput = $true
$info.RedirectStandardError = $true
foreach ($argument in $launch.arguments) { $info.ArgumentList.Add($argument) }
$process = [Diagnostics.Process]::new()
$process.StartInfo = $info
if (-not $process.Start()) { throw 'Network process did not start.' }
$started = $true
$stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null)
$stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null)
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $ReadinessScript -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly
if ($LASTEXITCODE -ne 0 -or $process.HasExited) { throw 'Network readiness failed; listener was not started.' }
if ($CheckOnly) { $code = 0 } else {
$runnerInfo = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe'))
$runnerInfo.UseShellExecute = $false
$runnerInfo.CreateNoWindow = $true
$runnerInfo.RedirectStandardOutput = $true
$runnerInfo.RedirectStandardError = $true
foreach ($argument in @('-NoProfile','-NonInteractive','-File',$StartScript,'-RunnerRoot',$RunnerRoot,'-ConfigRoot',$ConfigRoot)) { $runnerInfo.ArgumentList.Add($argument) }
$runner = [Diagnostics.Process]::new()
$runner.StartInfo = $runnerInfo
if (-not $runner.Start()) { throw 'Runner process did not start.' }
$runnerStarted = $true
$runnerOut = $runner.StandardOutput.BaseStream.CopyToAsync([Console]::OpenStandardOutput())
$runnerErr = $runner.StandardError.BaseStream.CopyToAsync([Console]::OpenStandardError())
while (-not $runner.WaitForExit(1000)) {
if ($started -and -not $process.HasExited) { continue }
if ($started) {
Write-Output "Network process exited (code $($process.ExitCode)); runner retained. Inspect local network configuration if recovery repeats."
$started = $false
Remove-Item -LiteralPath $recordPath
$process.Dispose()
$process = $null
}
# The runner's lifetime owns recovery; waiting on it also cancels the retry delay.
if ($runner.WaitForExit($launch.restartDelaySeconds * 1000)) { break }
$process = [Diagnostics.Process]::new()
$process.StartInfo = $info
try { $started = $process.Start() }
catch {
# Launch errors can contain private arguments or paths.
$started = $false
}
if (-not $started) {
$process.Dispose()
$process = $null
Write-Output 'Network process restart failed; runner retained. Inspect the local executable and configuration; recovery will retry.'
continue
}
$stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null)
$stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null)
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath
$restarts++
Write-Output "Network process restarted (attempt $restarts); runner retained. Connectivity recovery is handled by the runner."
}
$code = $runner.ExitCode
}
} catch {
# Do not expose provider paths, arguments, configuration or raw error messages.
[Console]::Error.WriteLine('Private network supervision failed; inspect local state. No job was retried.')
if ($runnerStarted -and -not $runner.HasExited) {
[Console]::Error.WriteLine('Network recovery stopped; the existing runner remains running until it exits. Inspect local supervision state before maintenance.')
$runner.WaitForExit()
}
} finally {
if ($runnerStarted) {
try {
if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($runnerOut,$runnerErr), 5000)) { throw 'Output drain timed out.' }
} catch {
$code = 1
[Console]::Error.WriteLine('Runner output drain failed or timed out; output may be incomplete.')
}
}
if ($runner) { $runner.Dispose() }
try {
if ($started) {
if (-not $process.HasExited) { $process.Kill($true) }
if (-not $process.WaitForExit(10000)) { throw 'Network cleanup did not complete.' }
if (Test-Path -LiteralPath $recordPath) { Remove-Item -LiteralPath $recordPath }
}
} catch {
$code = 1
[Console]::Error.WriteLine('Owned network cleanup failed; inspect local process state before restarting.')
} finally {
if ($process) { $process.Dispose() }
if ($lock) { $lock.Dispose() }
}
}
exit $code
@@ -15,7 +15,13 @@ try {
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
$startup = Join-Path $PSScriptRoot 'start.ps1'
if ($config.NetworkRoot) {
if (-not (Test-Path -LiteralPath (Join-Path $config.ConfigRoot 'runner-network.json') -PathType Leaf)) { throw 'Owned network supervision requires runner-network.json.' }
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'run-windows-runner-network-process.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot -NetworkRoot $config.NetworkRoot -ReadinessScript $startup -StartScript $startup *> $log
} else {
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $startup -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.
+2 -2
View File
@@ -11,7 +11,7 @@ import { withWindowsSigningStage } from './windows-signing-stage.mjs'
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
import { verifyDesktopRuntime, writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
import { verifyDesktopRuntime, writeDesktopRuntimeAsync, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
/**
@@ -39,7 +39,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string,
options: WindowsCodeSigningOptions & { smoke: (descriptor: DesktopRuntimeDescriptor) => Promise<void> }): Promise<void> {
const descriptor = await verifyDesktopRuntime(root, version)
await signWindowsCode(root, options)
writeDesktopRuntime(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name))
await writeDesktopRuntimeAsync(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name))
const signed = await verifyDesktopRuntime(root, version)
await options.smoke(signed)
await verifyDesktopRuntime(root, version)
@@ -16,6 +16,9 @@ if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a f
$env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot)
$env:DSH_TELEMETRY_DISABLED='1'
$env:POWERSHELL_TELEMETRY_OPTOUT='1'
. (Join-Path $PSScriptRoot 'windows-runner-network.ps1')
$network = Read-RunnerNetwork $ConfigRoot
Set-RunnerNetwork $network
foreach($tool in @('pwsh','node','git','tar','python')) {
$resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1
Write-Output ('Tool: '+$tool+' -> '+$resolved.Source)
@@ -31,6 +34,7 @@ $vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/v
$visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'}
Write-Output 'Visual C++ tools located.'
Wait-RunnerNetwork $network
if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0}
if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'}
Set-Location $RunnerRoot
@@ -0,0 +1,19 @@
/** Identity of a Windows packaging run and its independent build root. */
export interface WindowsBuildWorkspaceOptions {
sourceDirectory: string
buildRoot: string
runId: string
runAttempt: string
sourceSha: string
}
/**
* Clone the clean selected commit into a new run-owned directory.
* @param options - Original checkout and Actions run identity.
* @returns Checkout and temporary directory owned by this run.
*/
export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string; temporaryDirectory: string }
/**
* Remove the directory only when its ownership record matches the run.
* @param options - Original checkout and Actions run identity.
*/
export function removeWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): void
@@ -0,0 +1,96 @@
/** Create and remove a run-owned checkout for Windows packaging. */
import { execFileSync } from 'node:child_process'
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute, join, parse, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
function assertUnlinkedAncestors(directory) {
for (let current = directory; ; current = dirname(current)) {
if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
throw new Error('Packaging workspace ancestors must not be links')
}
if (dirname(current) === current) break
}
}
function locations(options) {
const { buildRoot, runId, runAttempt, sourceSha } = options
if (!buildRoot || !isAbsolute(buildRoot) || /[\r\n\0]/.test(buildRoot)) {
throw new Error('Packaging build root must be a nonempty absolute directory')
}
const root = resolve(buildRoot)
if (root === parse(root).root) throw new Error('Packaging build root must not be a volume root')
if (!/^[1-9]\d*$/.test(runId) || !/^[1-9]\d*$/.test(runAttempt) || !/^[a-f0-9]{40}$/.test(sourceSha)) {
throw new Error('Packaging workspace requires a run identity and exact commit')
}
assertUnlinkedAncestors(root)
for (let current = root; ; current = dirname(current)) {
if (existsSync(join(current, '.git'))) throw new Error('Packaging build root must be outside existing Git checkouts')
if (dirname(current) === current) break
}
const owner = join(root, `windows-package-${runId}-${runAttempt}`)
assertUnlinkedAncestors(owner)
return { root, owner, directory: join(owner, 'source'), temporaryDirectory: join(owner, 'temp') }
}
function git(directory, args) {
return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
}
function ownership(options, paths) {
return JSON.stringify({ version: 1, root: paths.root, source: resolve(options.sourceDirectory), runId: options.runId, runAttempt: options.runAttempt, sourceSha: options.sourceSha })
}
/**
* Clone the validated commit into a new run-owned checkout; failures retain diagnostics.
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity.
* @returns {{directory: string, temporaryDirectory: string}} Checkout and temporary directory owned by this run.
*/
export function prepareWindowsBuildWorkspace(options) {
const paths = locations(options)
if (git(options.sourceDirectory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(options.sourceDirectory, ['status', '--porcelain'])) {
throw new Error('Packaging source must be clean and match the selected commit')
}
mkdirSync(paths.root, { recursive: true })
mkdirSync(paths.owner)
writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' })
mkdirSync(paths.temporaryDirectory)
git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory])
git(paths.directory, ['checkout', '--detach', options.sourceSha])
if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) {
throw new Error('Isolated packaging checkout does not match the selected commit')
}
return { directory: paths.directory, temporaryDirectory: paths.temporaryDirectory }
}
/**
* Remove only the matching run-owned directory after successful artifact upload.
* Transient filesystem failures retry up to three times; persistent failures propagate.
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity.
* @returns {void}
*/
export function removeWindowsBuildWorkspace(options) {
const paths = locations(options)
const marker = join(paths.owner, 'owner.json')
const stat = lstatSync(marker)
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) {
throw new Error('Packaging workspace ownership does not match this run')
}
rmSync(paths.owner, { recursive: true, maxRetries: 3, retryDelay: 100 })
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const options = {
sourceDirectory: process.env.GITHUB_WORKSPACE ?? '',
buildRoot: process.env.PACKAGE_BUILD_ROOT ?? '',
runId: process.env.GITHUB_RUN_ID ?? '',
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '',
sourceSha: process.env.PACKAGE_SOURCE_SHA ?? '',
}
if (process.argv[2] === '--remove') removeWindowsBuildWorkspace(options)
else {
if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments')
const result = prepareWindowsBuildWorkspace(options)
appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\ntemporaryDirectory=${result.temporaryDirectory}\n`)
}
}
@@ -1,6 +1,7 @@
/**
* Validate file-owned Windows packaging and cache worker limits before packaging starts.
* @param environment Windows packaging settings loaded from .env.windows.
* @returns Cache workers from one to eight and a positive integer pack limit; both default to four. Invalid values throw.
* Validate worker limits and compression before packaging starts.
* File-owned compression takes precedence over the existing electron-builder environment override.
* @param environment Loaded packaging settings and inherited build environment.
* @returns Cache workers from one to eight and a positive integer pack limit, both defaulting to four; archive compression from zero to nine, defaulting to three. Invalid values throw.
*/
export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number }
export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number }
@@ -1,9 +1,10 @@
/** Resolve file-owned Windows packaging and signature-cache concurrency. */
/** Resolve Windows packaging concurrency and archive compression. */
/**
* Validate packaging and cache worker limits without accessing signing hardware.
* @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings.
* @returns {{signatureCacheConcurrency: number, packConcurrency: number}} Worker limits, each defaulting to four when omitted.
* Validate worker limits and compression without accessing signing hardware.
* File-owned compression takes precedence over the existing electron-builder environment override.
* @param {NodeJS.ProcessEnv} environment Loaded packaging settings and inherited build environment.
* @returns {{signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number}} Worker limits defaulting to four and archive compression defaulting to three.
*/
export function resolveWindowsPackageSettings(environment) {
const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4'
@@ -14,5 +15,9 @@ export function resolveWindowsPackageSettings(environment) {
if (!/^[1-9]\d*$/u.test(pack) || !Number.isSafeInteger(Number(pack))) {
throw new Error('desktop package: DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY must be a positive integer')
}
return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack) }
const compression = environment.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL ?? environment.ELECTRON_BUILDER_COMPRESSION_LEVEL ?? '3'
if (!/^[0-9]$/u.test(compression)) {
throw new Error('desktop package: DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL must be an integer from 0 to 9')
}
return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack), compressionLevel: Number(compression) }
}
@@ -0,0 +1,59 @@
# Normalize only this workflow shell and the children it subsequently launches.
[CmdletBinding()]
param([switch]$InspectOnly, [switch]$BestEffort)
$ErrorActionPreference = 'Stop'
if (-not $IsWindows) { throw 'Packaging process priority requires Windows.' }
if (-not ('DshPackagingPriority' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class DshPackagingPriority {
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("ntdll.dll")] static extern int NtSetInformationProcess(IntPtr process, int kind, ref int value, int size);
[DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr process, int kind, out int value, int size, out int length);
public static int Read(int kind) {
int value, length;
int status = NtQueryInformationProcess(GetCurrentProcess(), kind, out value, 4, out length);
if (status != 0 || length != 4) throw new InvalidOperationException("Cannot read packaging priority: " + status);
return value;
}
public static void Set(int kind, int value) {
int status = NtSetInformationProcess(GetCurrentProcess(), kind, ref value, 4);
if (status != 0) throw new InvalidOperationException("Cannot set packaging priority: " + status);
}
}
'@
}
$process = [Diagnostics.Process]::GetCurrentProcess()
try {
$before = [ordered]@{
cpu = $process.PriorityClass.ToString()
io = [DshPackagingPriority]::Read(33)
memory = [DshPackagingPriority]::Read(39)
}
if (-not $InspectOnly) {
$process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal
# PROCESSINFOCLASS: ProcessIoPriority (33), ProcessPagePriority (39).
[DshPackagingPriority]::Set(33, 2)
[DshPackagingPriority]::Set(39, 5)
}
$process.Refresh()
$after = [ordered]@{
cpu = $process.PriorityClass.ToString()
io = [DshPackagingPriority]::Read(33)
memory = [DshPackagingPriority]::Read(39)
}
[ordered]@{ event = 'packaging-process-priority'; before = $before; after = $after } | ConvertTo-Json -Compress
if (-not $InspectOnly -and ($after.cpu -ne 'Normal' -or $after.io -ne 2 -or $after.memory -ne 5)) {
throw 'Packaging process did not reach normal CPU, I/O and memory priority.'
}
} catch {
if (-not $BestEffort) { throw }
Write-Warning "Packaging priority adjustment unavailable; performance comparison is invalid: $($_.Exception.Message)"
[ordered]@{ event = 'packaging-process-priority'; comparable = $false } | ConvertTo-Json -Compress
} finally {
$process.Dispose()
}
Loaded 100 of 1215 files, more files were not shown because too many files have changed in this diff. Show more