Merge feat/creation-recommendations into plugin recommendation experiment
This commit is contained in:
commit
caa5535dee
557 files changed
+14215
-4742
No files matched your search
@@ -9,8 +9,8 @@
|
||||
en: c8b932b02a4e1c80
|
||||
zh: 96c466be1a521f3f
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/decision:
|
||||
en: b57eb0f268033ff1
|
||||
zh: 4143382a50181414
|
||||
en: 2e08d801099cbe18
|
||||
zh: 8c5ab162ada9cc41
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/package-topology:
|
||||
en: 5ea3ddfae82df181
|
||||
zh: 2d05d95e51081696
|
||||
|
||||
@@ -32,7 +32,7 @@ Search and fetch are separate tools but one web-access seam. `ctx.web` owns prov
|
||||
- A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable.
|
||||
- The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run.
|
||||
|
||||
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
|
||||
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` while the selected provider reports that the request's search cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
|
||||
|
||||
The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13
|
||||
- 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。
|
||||
- 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。
|
||||
|
||||
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。
|
||||
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。发行版的 `dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的搜索无法鉴权时,它会[撤下 `web_search`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。
|
||||
|
||||
该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-web-search-offered-by-provider-readiness.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready:
|
||||
en: 9163af91ea0e030d
|
||||
zh: 92e44043b999d32f
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/problem:
|
||||
en: 6fac3c73a4415c83
|
||||
zh: f65b61cc1705348b
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/decision:
|
||||
en: 458416ed7e47f644
|
||||
zh: 8b4dd820228e9dfd
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/alternatives-considered:
|
||||
en: b731254983be656d
|
||||
zh: 5dee2b09f7faa537
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/consequences:
|
||||
en: 798faeaa0b406eac
|
||||
zh: 97bbfdaebc84b1d4
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Agent Note: Offer web_search and web_fetch only while the selected provider is ready
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-web-search-offered-by-provider-readiness.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The shipped search provider, `dsh-web-search-deepseek`, authenticates a search with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. A Session on an API-key route without a search key was therefore offered a tool whose every call failed with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Search and Fetch APIs of `dsh-web-deepseek-api`, which the Web search settings switch selects together, need that credential for both operations, so the same Session was also offered a `web_fetch` whose every call failed.
|
||||
|
||||
## Decision
|
||||
|
||||
Each role of the web capability seam owns one part of a per-step check:
|
||||
|
||||
- **Service Definition.** `WebSearchProvider` and `WebFetchProvider` have an optional `ready(llmProvider)` that answers whether an operation made for a model request on that chat route has a credential to send. It checks configuration only and makes no network calls. `ctx.web.searchReady(llmProvider?)` and `ctx.web.fetchReady(llmProvider?)` resolve the provider with the same rules as `search()` and `fetch()` and return its answer. A provider without `ready()` counts as ready, and a selection failure answers true because the operation reports that failure as a structured `WebError`.
|
||||
- **Service Provider.** `dsh-web-search-deepseek` answers true when the route is `deepseek-account` and `ctx.deepseekAccount.resolveToken()` returns a token for the configured search endpoint, when the section carries a literal `apiKey`, or when `ctx.credentials.describe()` reports the `apiKeyEnv` reference configured (without that service, when the launching environment carries it). It checks credentials in the order `search()` resolves them, and a failed account-token or credential-store read answers false, because `search()` fails on that read instead of trying the next credential. The Search and Fetch API providers of `dsh-web-deepseek-api` share one credential and answer the same way for their API base; their transport repeats these checks, so neither package depends on the other. The providers name no tool or prompt section.
|
||||
- **Consumer.** `dsh-tool-web` registers `web_search` and `web_fetch` with [step activation conditions](../../../../packages/core/tools/README.md#conditional-tools) that ask `ctx.web.searchReady()` and `ctx.web.fetchReady()` about the route of the step's model selection. The agent loop resolves conditions before it assembles the step, so the declared tools, the `tool:web_search` and `tool:web_fetch` prompt sections, PTC program bindings, and dispatch all read the same admitted set.
|
||||
|
||||
The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. The providers do not compare the chat route's endpoint with their own: each operation sends the configured key, `DEEPSEEK_API_KEY` by default, to the configured endpoint, even when the official route chats through a gateway.
|
||||
|
||||
Only presence decides: no API key value is read and no endpoint is contacted, so a wrong key or an endpoint that does not serve the native `web_search_20250305` server tool keeps the tool admitted. A step that does not admit a web tool also refuses calls to it as `UNKNOWN_TOOL`, so a model that repeats an earlier call reaches no provider; an operation made outside a model step resolves its credential when it runs and fails with `WEB_PROVIDER_CREDENTIAL_MISSING` when there is none.
|
||||
|
||||
The request header records the assembled tools, so the agent loop announces each change through tool-registry updates. A route switch, a stored key, or a removed key changes the next request; DeepSeek routes load a returning tool as a deferred addition, and routes without tool-update support receive the complete tool list.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as Exa, whose searches would succeed.
|
||||
|
||||
**Extend `available()`.** It is synchronous and takes no request, while this check reads an asynchronous credential store and depends on the request's route. A route-dependent `available()` would also make provider selection depend on the conversation.
|
||||
|
||||
**Read the route from the Session's latest `request/context` event instead of passing it.** That event records the previous request, so a new Session's first request and the first request after a model switch would be decided for the wrong route or for none.
|
||||
|
||||
**Register or unregister `web_search` in `dsh-tool-web`.** Registration is shared by every Session of an agent preset, while credentials depend on each request's route. Unregistering would also make `toolOrder` entries and subagent `toolFilter` restrictions that name `web_search` fail.
|
||||
|
||||
**Remove `web_search` from the assembled request.** A `system-prompt/assemble` listener could drop the declaration and its section, but the tool would stay executable, so a model that repeats an earlier call would still run it, and PTC programs would still bind it. Prompt assembly also no longer carries tool declarations.
|
||||
|
||||
**Offer search only on the DeepSeek account route.** Account tokens are issued only for the official endpoint, so a gateway key could never reach DeepSeek. But every shipped profile defaults to the `deepseek-official` route, so search would be off by default, including the official API-key case.
|
||||
|
||||
**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Web search settings page saves its key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so a key entered there could never serve another model; a `routes` setting that opens them would be one more thing a user has to find.
|
||||
|
||||
**Compare the official chat route's endpoint with the search endpoint.** Withholding the tools or the key when the two endpoints differ in origin keeps a gateway's key away from DeepSeek, but the providers would have to find the chat route's configuration themselves. Taking the first active `dsh-llm-deepseek-api-key` Loader row answers for an unrelated instance when a composition isolates several, which disables a correct route or lets a gateway key through; only the route's owner knows which endpoint serves a request, and an endpoint field in `dsh-llm` would reach the browser through the Remote `listProviders()`. The check would also withhold search from users who chat through a proxy or development endpoint with a key the official search endpoint accepts. Without it, a gateway user's search fails at the configured endpoint.
|
||||
|
||||
**Keep the tool offered and fail at execution.** The model spends a call on a search that cannot authenticate, and the failure repeats on every attempt.
|
||||
|
||||
**Probe the key or endpoint before offering the tool.** A network request per model request adds latency and cost; the user controls configuration, not its validity at that moment.
|
||||
|
||||
## Consequences
|
||||
|
||||
`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The Exa, Perplexity, and HTTP fetch providers implement no `ready()`, so their behavior is unchanged, and `web_fetch` stays admitted whenever the switch leaves HTTP fetch selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key, and a deployment whose official route chats through a gateway sends the gateway's key to the configured endpoint, the official one by default, where each operation fails unless that endpoint accepts the key. The DeepSeek web packages read no Loader rows and depend on no LLM package.
|
||||
|
||||
Unit tests pin each role: `packages/web/web/tests/web.spec.ts` covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, `packages/web/web-search-deepseek/tests/deepseek.spec.ts` and `packages/web/web-deepseek-api/tests/readiness.spec.ts` cover each credential case of `ready()` on the account route and on other routes, and `packages/web/tool-web/tests/tool-web.spec.ts` resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Agent Note: 仅在选中的提供方就绪时提供 web_search 与 web_fetch
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-web-search-offered-by-provider-readiness.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。发行版的搜索提供方 `dsh-web-search-deepseek` 只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 鉴权;其他路由都需要已配置的 API 密钥。因此,API 密钥路由上没有搜索密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。Web 搜索设置开关会同时选中 `dsh-web-deepseek-api` 的 Search 与 Fetch API,两个操作都需要这份凭据,因此同一会话还会获得一个每次调用都失败的 `web_fetch`。
|
||||
|
||||
## Decision
|
||||
|
||||
web 能力 seam 的每个角色各负责逐步骤检查的一部分:
|
||||
|
||||
- **Service Definition。** `WebSearchProvider` 与 `WebFetchProvider` 有一个可选的 `ready(llmProvider)`,回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。它只检查配置,不发起网络调用。`ctx.web.searchReady(llmProvider?)` 与 `ctx.web.fetchReady(llmProvider?)` 按与 `search()`、`fetch()` 相同的规则解析提供方并返回其回答。没有 `ready()` 的提供方视为就绪;选择失败时回答 true,因为对应操作会把该失败报告为结构化 `WebError`。
|
||||
- **Service Provider。** `dsh-web-search-deepseek` 在以下任一情况下回答 true:路由为 `deepseek-account` 且 `ctx.deepseekAccount.resolveToken()` 为配置的搜索端点返回 token;配置段带有字面 `apiKey`;或 `ctx.credentials.describe()` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境中存在该变量)。它按 `search()` 解析凭据的顺序检查凭据,读取账号 token 或凭据存储失败时回答 false,因为 `search()` 会在该次读取上失败,而不会尝试下一种凭据。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方共用一份凭据,并对其 API 基础地址以同样方式回答;它们的传输层重复了这些检查,因此两个包互不依赖。提供方不点名任何工具或提示词段落。
|
||||
- **Consumer。** `dsh-tool-web` 注册 `web_search` 与 `web_fetch` 时各带一个[步骤激活条件](../../../../packages/core/tools/README.zh.md#conditional-tools),以该步骤模型选择的路由询问 `ctx.web.searchReady()` 与 `ctx.web.fetchReady()`。agent loop 在组装该步骤之前解析各条件,因此声明的工具、`tool:web_search` 与 `tool:web_fetch` 提示词段落、PTC 程序绑定与派发都读取同一个准入集合。
|
||||
|
||||
路由只决定是否在 API 密钥之前检查账号 token,这只发生在 `deepseek-account` 路由上;API 密钥在任何路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。提供方不比较聊天路由的地址与自己的端点:即使官方路由经网关聊天,每次操作也把配置的密钥(默认 `DEEPSEEK_API_KEY`)发往配置的端点。
|
||||
|
||||
只看凭据是否存在:不读取 API 密钥的值,也不访问任何端点,因此错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,仍会让该工具保持准入。未准入某个 Web 工具的步骤也会以 `UNKNOWN_TOOL` 拒绝对它的调用,因此模型重复先前的调用也到达不了任何提供方;在模型步骤之外发起的操作在运行时解析凭据,没有凭据时以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。
|
||||
|
||||
请求头记录组装后的工具,因此 agent loop(智能体循环)通过工具注册表更新宣告每次变化。路由切换、存储密钥或删除密钥都会改变下一个请求;DeepSeek 路由以延迟加载的新增方式载入重新提供的工具,不支持工具更新的路由则接收完整工具列表。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如 Exa),它也会移除该工具。
|
||||
|
||||
**扩展 `available()`。** 它是同步的且不接收请求,而这项检查要读取异步凭据存储,并取决于请求的路由。依赖路由的 `available()` 还会让提供方选择取决于对话。
|
||||
|
||||
**从会话最近的 `request/context` 事件读取路由,而不是传入路由。** 该事件记录的是上一个请求,因此新会话的首个请求,以及模型切换后的首个请求,会按错误的路由判断,或根本没有路由可用。
|
||||
|
||||
**在 `dsh-tool-web` 中注册或注销 `web_search`。** 注册由同一 agent preset 的所有会话共享,而凭据取决于每个请求的路由。注销还会使点名 `web_search` 的 `toolOrder` 条目和 subagent `toolFilter` 限制失败。
|
||||
|
||||
**从组装好的请求中移除 `web_search`。** `system-prompt/assemble` 监听器可以删掉该声明及其段落,但工具仍可执行,因此模型重复先前的调用时仍会运行它,PTC 程序也仍能绑定它。提示词组装也已不再携带工具声明。
|
||||
|
||||
**只在 DeepSeek 账号路由上提供搜索。** 账号 token 只对官方端点签发,因此网关的密钥不可能到达 DeepSeek。但所有发行 profile 的默认路由都是 `deepseek-official`,搜索会因此默认关闭,连官方 API 密钥的情况也一并关闭。
|
||||
|
||||
**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Web 搜索设置页把密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此在那里输入的密钥永远无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。
|
||||
|
||||
**比较官方聊天路由的端点与搜索端点。** 两个端点不同源时撤下工具或不发送密钥,能让网关的密钥远离 DeepSeek,但提供方得自己找出聊天路由的配置。取第一条激活的 `dsh-llm-deepseek-api-key` Loader 行时,如果组合隔离了多个实例,回答的可能是无关的实例,从而关掉正确的路由,或放过网关的密钥;只有路由的所有者知道哪个端点服务某个请求,而在 `dsh-llm` 加地址字段会让它经 Remote 方法 `listProviders()` 进入浏览器。这项检查还会让经代理或开发端点对话、而密钥能被官方搜索端点接受的用户失去搜索。不做这项检查时,网关用户的搜索会在配置的端点上失败。
|
||||
|
||||
**保持工具可见,在执行时失败。** 模型会把一次调用花在无法鉴权的搜索上,并且每次尝试都会重复失败。
|
||||
|
||||
**提供工具前探测密钥或端点。** 每个模型请求都发起一次网络请求会增加延迟与成本;用户控制的是配置本身,而不是它在那一刻是否有效。
|
||||
|
||||
## Consequences
|
||||
|
||||
`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。Exa、Perplexity 与 HTTP 抓取提供方没有实现 `ready()`,其行为不变;只要开关让 HTTP 抓取保持选中,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费;官方路由经网关对话的部署会把网关的密钥发往配置的端点(默认是官方端点),除非该端点接受这个密钥,否则每次操作都会失败。DeepSeek Web 包不读取 Loader 的行,也不依赖任何 LLM 包。
|
||||
|
||||
单元测试分别固定每个角色:`packages/web/web/tests/web.spec.ts` 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,`packages/web/web-search-deepseek/tests/deepseek.spec.ts` 与 `packages/web/web-deepseek-api/tests/readiness.spec.ts` 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,`packages/web/tool-web/tests/tool-web.spec.ts` 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/boot-the-same-profile-as-the-served-web-deployment:
|
||||
en: caf1ca77192dcb20
|
||||
zh: d663e2cbb05fe4fc
|
||||
en: 9b957e37f290abaf
|
||||
zh: 06a4599c8fe449ad
|
||||
/agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/node-internal-module-interfaces-in-the-worker:
|
||||
en: 37d8e809eaa21d25
|
||||
zh: 8c7c31bd6f45c823
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ Measured: with no bundle enabled, the two compositions differ in only one of 188
|
||||
The served launch path (apps/cli, app-boot) carries no Preview-specific code; the webworker packages reproduce it:
|
||||
|
||||
- **packer**
|
||||
- Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from app-boot, the same source the Plugins page uses.
|
||||
- Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from `@deepseek-ai/dsh-official-bundles`, the same source the Plugins page uses.
|
||||
- The image carries a manifest-only `node_modules/@deepseek-ai/dsh/package.json` (a verbatim copy of `apps/cli/package.json`) as the installation anchor, and the image manifest names it in `installation`.
|
||||
- The image carries no composed configuration and no `config/cordis.yml`; the image manifest records the `profile` the Worker boots.
|
||||
- The image carries a home patch, `home/cordis.patch.yml`: for each row the runtime's `PLATFORM_ENTRY_CONFIG` names (`session-persistence-jsonl`), the config the template's bundle layers compose with the required fields over it (`compression: none`). A patch replaces a row's whole config, so the composed fields stay; the bundle files read at pack time are the ones in the image.
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ Status: implemented
|
||||
正式启动链路(apps/cli、app-boot)不含 Preview 专用代码;由 webworker 包复现这条链路:
|
||||
|
||||
- **packer**
|
||||
- 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 app-boot 读,与插件页同一来源。
|
||||
- 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 `@deepseek-ai/dsh-official-bundles` 读,与插件页同一来源。
|
||||
- 镜像里放一份只含清单的 `node_modules/@deepseek-ai/dsh/package.json`(原样复制 `apps/cli/package.json`),作为安装锚点,镜像 manifest 在 `installation` 里记录它。
|
||||
- 镜像不带组合好的配置,也没有 `config/cordis.yml`;镜像 manifest 记录 Worker 要启动的 `profile`。
|
||||
- 镜像带一份 home patch `home/cordis.patch.yml`:对运行时 `PLATFORM_ENTRY_CONFIG` 列出的每一行(`session-persistence-jsonl`),写入模板各 bundle 层合成出的配置,并在其上设置所需字段(`compression: none`)。补丁会整体替换一行的配置,所以保留合成出的字段;打包时读取的 bundle 文件就是镜像里的那些。
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-file-drag-plugin-ownership.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-file-drag-plugin-ownership.md
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout:
|
||||
en: 951575b08912a949
|
||||
zh: f33e8e6bcb9240bd
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout/problem:
|
||||
en: 557edc1db9bee8d8
|
||||
zh: 20f47c78c8b0078b
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout/decision:
|
||||
en: 1dfea60901d6e6ae
|
||||
zh: e4fc534dbe01b0d5
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout/alternatives-considered:
|
||||
en: 571eabab132ce4c1
|
||||
zh: b5f8207015aeb3be
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout/consequences:
|
||||
en: 56ebb7c25911fd61
|
||||
zh: 88120d2af74785ef
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: File drag ownership outside the application layout
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-file-drag-plugin-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Image dragging spans conversation images, sidebar previews, and composer drop targets. Original files must survive source unmount during a drag, and failures must remain visible after the originating panel closes. An application-root image provider puts feature state in layout and makes independent image entries depend on an undeclared React ancestor.
|
||||
|
||||
## Decision
|
||||
|
||||
The [file-drag plugin](../../../../packages/client/ui-file-drag/README.md) owns generic file preparation, browser transfer tokens, retained original files, desktop bridge selection, and transient failure feedback. Consumers receive preparation and error callbacks through their slot or Factory injection. The plugin registers its feedback through `shell.overlay`; layout declares the generic slot and has no image dependency. The shared image primitive owns gestures and cancellation. Images are the first consumer, while MIME naming and transport accept other files.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Root React provider.** It preserves transfer lifetime but adds image-specific assembly to the layout package and hides dependencies from slot registration. Moving the same wrapper into the renderer would move the feature coupling into another generic owner.
|
||||
|
||||
**State on each image.** It avoids shared assembly but cannot deliver the retained file after the source unmounts or keep a late failure visible after the panel closes.
|
||||
|
||||
**Direct browser File only.** Manual macOS Chrome 154.0.8037.93 drags of PNG and PDF fixtures produced a populated `dataTransfer.files` in trusted `dragstart`, but empty files in trusted same-page and cross-window drops. Finder received filename-only text clippings. These measured failures require the application-local token path; they do not establish behavior in untested browsers or cross-tab delivery. Desktop retains Electron native file dragging.
|
||||
|
||||
## Consequences
|
||||
|
||||
One plugin lifetime governs transfer state, a native source-node end listener, and drop completion. The source listener remains attached after React unmount. Independent render roots can share a transfer without sharing a React ancestor. Custom compositions must load the service and declare the feedback slot; custom image Factory registrations supply the required callbacks. Application-local browser tokens are accepted only over the composer; native Desktop files retain the full-page drop overlay. Plugin disposal removes feedback and clears retained files. Loader-backed browser scenarios cover source removal and delivery, while focused tests cover local resource authorization and cleanup failures.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: 应用布局之外的文件拖放归属
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-file-drag-plugin-ownership.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
图片拖放跨越会话图片、侧栏预览和输入框接收区。拖动期间原始文件必须在源组件卸载后继续保留,发起操作的面板关闭后失败提示也必须可见。应用根部的图片 Provider 把功能状态放进布局,并使独立图片入口依赖未声明的 React 祖先组件。
|
||||
|
||||
## Decision
|
||||
|
||||
[文件拖放插件](../../../../packages/client/ui-file-drag/README.zh.md) 持有通用文件准备、浏览器传递令牌、原始文件、桌面桥接选择和临时失败提示。消费方通过 slot 或 Factory 注入接收准备及错误回调。插件通过 `shell.overlay` 注册提示,布局只声明通用插槽,不依赖图片功能。共享图片原语负责手势与取消。图片是首个调用方,MIME 命名与传输也接受其他文件。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**根部 React Provider。** 它能保留传递生命周期,但会把图片专属组合放进布局包,并对 slot 注册隐藏依赖。把同一个包裹组件移到渲染器,只会把功能耦合转移到另一个通用模块。
|
||||
|
||||
**每张图片独立持有状态。** 这能避免共享组合,却不能在源组件卸载后交付保留文件,也不能在面板关闭后继续显示延迟到达的失败提示。
|
||||
|
||||
**仅直接传递浏览器 File。** 在 macOS Chrome 154.0.8037.93 中手动拖放 PNG 与 PDF 时,可信 `dragstart` 中 `dataTransfer.files` 有文件,同页与跨窗口的可信 drop 却收到空文件列表。Finder 收到仅含文件名的文本摘录。这些实测失败要求保留应用内令牌路径,但不能据此判定未测试浏览器或跨标签页交付的行为。Desktop 保留 Electron 原生文件拖放。
|
||||
|
||||
## Consequences
|
||||
|
||||
一个插件生命周期管理传递状态、源节点上的原生结束监听器与投放完成。React 卸载后,监听器仍绑定源节点。独立渲染根可以共享传递,而不共享 React 祖先组件。自定义组合必须加载服务并声明反馈插槽,自定义图片 Factory 注册提供所需回调。应用内浏览器令牌仅在输入框范围接受;Desktop 原生文件保留全页投放遮罩。插件卸载会移除提示并清除保留文件。通过 Loader 启动的浏览器场景覆盖源图移除与交付,定向测试覆盖本地资源授权和清理失败。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-21-replaceable-icon-sprite.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-replaceable-icon-sprite.md
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite:
|
||||
en: 2b527e550e071451
|
||||
zh: 7de256912270cae0
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite/problem:
|
||||
en: d55557fe41ab87c8
|
||||
zh: eea3f7d0c82561bf
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite/decision:
|
||||
en: 6ddd80eae56693dd
|
||||
zh: 4d654a76eb64c50a
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite/alternatives-considered:
|
||||
en: 2e25ece88bccdb22
|
||||
zh: bb24a28bef7ea7ce
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite/consequences:
|
||||
en: 0ad6db37bbb76ddb
|
||||
zh: 0ca09d55a2167970
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Replaceable product icons through an SVG sprite
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-21-replaceable-icon-sprite.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Long-term skin customization needs themes to replace icons, not only colors. Product icons were React components that drew inline SVG paths, so a theme registered through `ctx.theme` could recolor them through `currentColor` but could not change their artwork. About 150 Client files import these components.
|
||||
|
||||
## Decision
|
||||
|
||||
Each product glyph weight is one sprite symbol, `dsw-icon-<glyph>-<weight>`. ui-primitives keeps the default SVG documents as `ICON_SYMBOLS`; an icon component keeps its export name, `size`, and `className`, and renders an `<svg>` box with one `<use>` of its symbol. ui-theme keeps one hidden sprite in the document whose `<symbol>` children come from the active theme's `ThemeDefinition.icons` replacements or the defaults. Artwork reached through `<use>` inherits `currentColor` and CSS custom properties from the use site, so a replacement can be multi-color, can paint with `--dsw-*` color tokens, and follows light and dark palettes without separate values.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Icons as CSS image tokens painted through `mask-image`.** Rejected after design review: a mask keeps only the image's alpha, so every icon stays one color, and a `url()` image cannot read CSS variables, so multi-color artwork could not follow color tokens. It would have reused the token override layer without a new theme field.
|
||||
- **A runtime icon registry service with React components as values.** Rejected: components cannot read services or React context, so every icon import site would need a new prop or slot.
|
||||
- **A slot per icon.** Rejected for the same caller cost; slots carry composition, not artwork.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Icon DOM contains a `<use>` reference instead of paths; tests identify glyphs through `data-icon` and inspect artwork through `ICON_SYMBOLS`.
|
||||
- Without ui-theme, the sprite is absent and icons render nothing.
|
||||
- Every symbol shares the document id space, so `register` requires element ids inside a replacement to start with `<symbol id>-`; `<use>` in another document, such as an iframe, cannot reach the sprite.
|
||||
- Replacement markup is trusted like the registering plugin's code; `register` rejects `<style>` and `<script>` only because they would apply to the whole document.
|
||||
- Gradients and other paint servers resolve their stops where the sprite sits, so they follow palette tokens but not use-site color overrides.
|
||||
- Default and token-colored replacement symbols render pixel-identically through `<use>` and inline in Chromium, WebKit, and Firefox.
|
||||
- File-type, plugin, site, and brand artwork still render inline and are not replaceable yet.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 通过 SVG sprite 实现可替换的产品图标
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-21-replaceable-icon-sprite.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
长期的皮肤定制需要主题能替换图标,而不只是颜色。产品图标原先是绘制内联 SVG 路径的 React 组件,因此通过 `ctx.theme` 注册的主题可以借助 `currentColor` 给它们换色,却无法更换图形。约 150 个 Client 文件导入了这些组件。
|
||||
|
||||
## 决策
|
||||
|
||||
每个产品图形的每种线重对应一个 sprite symbol `dsw-icon-<glyph>-<weight>`。ui-primitives 以 `ICON_SYMBOLS` 保存默认 SVG 文档;图标组件保留导出名、`size` 与 `className`,渲染一个 `<svg>` 框,其中包含一个引用自身 symbol 的 `<use>`。ui-theme 在文档中保留一个隐藏的 sprite,其 `<symbol>` 子元素来自活动主题的 `ThemeDefinition.icons` 替换项或默认值。通过 `<use>` 引用的图形会从使用处继承 `currentColor` 与 CSS 自定义属性,因此替换图形可以是多色的,可以用 `--dsw-*` 颜色 token 绘制,并且无需单独的值就能跟随浅色与深色调色板。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **把图标作为通过 `mask-image` 绘制的 CSS 图像 token。** 经设计评审后否决:遮罩只保留图像的 alpha,因此每个图标都只能是单色;`url()` 图像无法读取 CSS 变量,因此多色图形无法跟随颜色 token。该方案本可复用 token 覆盖层,而无需新增主题字段。
|
||||
- **以 React 组件为值的运行时图标注册服务。** 否决:组件无法读取服务或 React context,因此每个图标导入处都需要新的 prop 或 slot。
|
||||
- **每个图标一个 slot。** 因同样的调用方成本而否决;slot 承载的是组合,而不是图形。
|
||||
|
||||
## 后果
|
||||
|
||||
- 图标 DOM 包含 `<use>` 引用而不是路径;测试通过 `data-icon` 识别图形,通过 `ICON_SYMBOLS` 检查图形。
|
||||
- 没有 ui-theme 时,sprite 不存在,图标不渲染任何内容。
|
||||
- 所有 symbol 共享文档的 id 空间,因此 `register` 要求替换图形内的元素 id 以 `<symbol id>-` 开头;另一个文档(例如 iframe)中的 `<use>` 无法引用该 sprite。
|
||||
- 替换标记与注册它的插件代码同等受信任;`register` 仅拒绝 `<style>` 与 `<script>`,因为它们会作用于整个文档。
|
||||
- 渐变及其他绘制服务器在 sprite 所在位置解析其色标,因此它们跟随调色板 token,但不跟随使用处的颜色覆盖。
|
||||
- 默认 symbol 与使用 token 着色的替换 symbol 在 Chromium、WebKit 与 Firefox 中通过 `<use>` 渲染与内联渲染的结果逐像素一致。
|
||||
- 文件类型、插件、站点与品牌图形仍以内联方式渲染,暂不可替换。
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 4fb1c27a631b32d3
|
||||
zh: 9dbe9a4b9aadfca9
|
||||
/agent-note-serial-cross-platform-ci-reference/decision:
|
||||
en: c88f606c0749bd67
|
||||
zh: b2038b0d92fd8890
|
||||
en: b78b36281749773b
|
||||
zh: c4ed34782577e5e7
|
||||
/agent-note-serial-cross-platform-ci-reference/alternatives-considered:
|
||||
en: 3610152c5c4e93af
|
||||
zh: f4c09d327716f750
|
||||
|
||||
@@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) (pull-request-only) and [CI master](../../../../.github/workflows/ci-master.yml) (master-push + workflow_dispatch) give pull-request and master-push events complementary responsibilities. Pull requests run Linux, native Windows, [Node compatibility with self-hosted routing under the Linux variable and trust conditions](../../../../.github/workflows/ci.yml), and Python checks; [platform scheduling](../../../../.github/workflows/ci-master.yml) assigns Wine and three Python runtime carriers to master pushes. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition; the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
|
||||
[CI](../../../../.github/workflows/ci.yml), [CI Windows](../../../../.github/workflows/ci-windows.yml), and [CI Python](../../../../.github/workflows/ci-python.yml) (pull-request-only) and [CI master](../../../../.github/workflows/ci-master.yml) (master-push + workflow_dispatch) give pull-request and master-push events complementary responsibilities. Pull requests run Linux, native Windows, [Node compatibility with self-hosted routing under the Linux variable and trust conditions](../../../../.github/workflows/ci.yml), and Python checks; [platform scheduling](../../../../.github/workflows/ci-master.yml) assigns Wine and three Python runtime carriers to master pushes. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition; the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
|
||||
|
||||
Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml)(仅 pull request)与 [CI master](../../../../.github/workflows/ci-master.yml)(master 推送 + `workflow_dispatch`)为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求运行 Linux、原生 Windows、[仅在 Linux 变量与信任条件满足时使用自托管路由的 Node 兼容性](../../../../.github/workflows/ci.yml)与 Python 检查;[平台调度](../../../../.github/workflows/ci-master.yml)将 Wine 与三个 Python 运行时载体分配给 master 推送。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义;标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。
|
||||
[CI](../../../../.github/workflows/ci.yml)、[CI Windows](../../../../.github/workflows/ci-windows.yml) 与 [CI Python](../../../../.github/workflows/ci-python.yml)(仅 pull request)与 [CI master](../../../../.github/workflows/ci-master.yml)(master 推送 + `workflow_dispatch`)为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求运行 Linux、原生 Windows、[仅在 Linux 变量与信任条件满足时使用自托管路由的 Node 兼容性](../../../../.github/workflows/ci.yml)与 Python 检查;[平台调度](../../../../.github/workflows/ci-master.yml)将 Wine 与三个 Python 运行时载体分配给 master 推送。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义;标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。
|
||||
|
||||
每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
en: 677b522817e84e2f
|
||||
zh: f8518a7033b0fd54
|
||||
/agent-note-ci-failover-runbook-hosted-pools-in-house-pool/problem:
|
||||
en: 3ecedc74cceced3b
|
||||
zh: f4708f1887d42c61
|
||||
en: bcdd24f3c39d593c
|
||||
zh: 3e576d09c8c3bfd4
|
||||
/agent-note-ci-failover-runbook-hosted-pools-in-house-pool/decision:
|
||||
en: 4a76615718dbc8f0
|
||||
zh: f46b885a11d5dd1d
|
||||
en: fed13ca92e488b69
|
||||
zh: 9e5c9cdc118ab84e
|
||||
/agent-note-ci-failover-runbook-hosted-pools-in-house-pool/decision/release-rehearsals-share-the-linux-switch:
|
||||
en: 01e74068e0a96375
|
||||
zh: aa811861ce30cb47
|
||||
|
||||
@@ -6,11 +6,11 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`; the [native Windows jobs](2026-08-08-native-windows-pull-request-ci.md) run on the hosted `dsh-windows-2025-16core` larger runner. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: two independent switches, one per platform.** `DSH_CI_FAILOVER_LINUX` recovers an enterprise Linux-pool outage (the three required Linux workers plus the `all checks passed` verdict); `DSH_CI_FAILOVER_WINDOWS` recovers a hosted Windows-pool outage (the native Windows jobs). A Linux-pool outage need not retarget Windows jobs and vice versa. The [Node compatibility jobs](../../../../.github/workflows/ci.yml) also follow the Linux switch with isolated setup; the verdict's `node-24-bench`, `python-sdk`, and `python-runtime` dependencies stay on standard hosted runners; in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`. An outage therefore needs a switch any responder with repository write access can throw without merging anything.
|
||||
The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`; the [native Windows jobs](2026-08-08-native-windows-pull-request-ci.md) in [CI Windows](../../../../.github/workflows/ci-windows.yml) run on the hosted `dsh-windows-2025-16core` larger runner. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: two independent switches, one per platform.** `DSH_CI_FAILOVER_LINUX` recovers an enterprise Linux-pool outage (the three required Linux workers plus the `all checks passed`, `windows checks passed`, and `python checks passed` verdicts); `DSH_CI_FAILOVER_WINDOWS` recovers a hosted Windows-pool outage (the native Windows jobs). A Linux-pool outage need not retarget Windows jobs and vice versa. The [Node compatibility jobs](../../../../.github/workflows/ci.yml) also follow the Linux switch with isolated setup; the verdict's `node-24-bench`, `python-sdk`, and `python-runtime` dependencies stay on standard hosted runners, as does CI Python's `python-runtime`; in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block the verdicts that require them. An outage therefore needs a switch any responder with repository write access can throw without merging anything.
|
||||
|
||||
## Decision
|
||||
|
||||
The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the two `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes.
|
||||
The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the two `node-compat` matrix entries, and the `all-checks-passed`, `windows-checks-passed`, and `python-checks-passed` verdicts resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The three verdict jobs follow the Linux switch so they do not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes.
|
||||
|
||||
The [superseded-CI cancellation policy](../../../../.github/workflows/ci.yml) governs master pushes and manual runs in the same workflow/ref group, including standby drills. Rapid master updates can starve a drill before it reaches a verdict. Use the latest completed standby verdict and check its age and commit before treating it as readiness evidence; a cancelled or merely scheduled run is not proof of readiness.
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上;[原生 Windows 作业](2026-08-08-native-windows-pull-request-ci.zh.md)运行在托管的 `dsh-windows-2025-16core` 大型运行器上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:两个独立开关,每个平台一个。**`DSH_CI_FAILOVER_LINUX` 恢复企业级 Linux 池故障(三个必需的 Linux 工作作业加 `all checks passed` 判定作业);`DSH_CI_FAILOVER_WINDOWS` 恢复托管 Windows 池故障(原生 Windows 作业)。Linux 池故障无需重定向 Windows 作业,反之亦然。[Node 兼容性作业](../../../../.github/workflows/ci.yml)也通过隔离设置跟随 Linux 开关;判定作业的 `node-24-bench`、`python-sdk` 和 `python-runtime` 依赖仍留在标准托管运行器上;若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。
|
||||
[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上;[原生 Windows 作业](2026-08-08-native-windows-pull-request-ci.zh.md)(位于 [CI Windows](../../../../.github/workflows/ci-windows.yml))运行在托管的 `dsh-windows-2025-16core` 大型运行器上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:两个独立开关,每个平台一个。**`DSH_CI_FAILOVER_LINUX` 恢复企业级 Linux 池故障(三个必需的 Linux 工作作业加 `all checks passed`、`windows checks passed` 与 `python checks passed` 判定作业);`DSH_CI_FAILOVER_WINDOWS` 恢复托管 Windows 池故障(原生 Windows 作业)。Linux 池故障无需重定向 Windows 作业,反之亦然。[Node 兼容性作业](../../../../.github/workflows/ci.yml)也通过隔离设置跟随 Linux 开关;判定作业的 `node-24-bench`、`python-sdk` 和 `python-runtime` 依赖仍留在标准托管运行器上,CI Python 的 `python-runtime` 也是如此;若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞要求它们的判定。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。
|
||||
|
||||
## 决策
|
||||
|
||||
三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、两个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。
|
||||
三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、两个 `node-compat` 矩阵条目,以及 `all-checks-passed`、`windows-checks-passed`、`python-checks-passed` 三个判定作业通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。三个判定作业跟随 Linux 开关,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。
|
||||
|
||||
[被取代 CI 的取消策略](../../../../.github/workflows/ci.yml) 管理同一工作流/引用组内的 master 推送和手动运行,包括热备演练。master 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。
|
||||
|
||||
|
||||
+6
-6
@@ -9,11 +9,11 @@
|
||||
en: 3e229ecf646af866
|
||||
zh: d358507b1e1d381f
|
||||
/agent-note-wine-and-native-windows-ci/decision:
|
||||
en: 5a74ef2d98f6b4cf
|
||||
zh: 4a539303a3cd4ed8
|
||||
en: 1125b44a170f70fb
|
||||
zh: 37838b9da2003651
|
||||
/agent-note-wine-and-native-windows-ci/alternatives-considered:
|
||||
en: f6c5e77fe3f05739
|
||||
zh: 8de640d12d3a38c4
|
||||
en: 0e9a322cfc38d313
|
||||
zh: f7bdeb86ba4fea1a
|
||||
/agent-note-wine-and-native-windows-ci/consequences:
|
||||
en: 58069228d4e904c6
|
||||
zh: 2601a48b82400903
|
||||
en: 3dc1ae32e0a74331
|
||||
zh: ab0e9d00aad92d62
|
||||
@@ -14,9 +14,9 @@ A coverage audit found that stale branch state had restored temporary exclusions
|
||||
|
||||
The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](../../../../.github/workflows/ci-master.yml). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
|
||||
|
||||
Every pull request starts three native jobs: `windows-build`, `windows-coverage`, and `windows-native-tests`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes.
|
||||
Every pull request starts three native jobs in the separate [CI Windows workflow](../../../../.github/workflows/ci-windows.yml): `windows-build`, `windows-coverage`, and `windows-native-tests`. GitHub re-runs failed jobs only after every job of a run finishes, so a separate workflow lets maintainers re-run a failed Linux job while Windows jobs are still running, and the reverse. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes.
|
||||
|
||||
`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict.
|
||||
`windows-build` and `windows-native-tests` are dependencies of the required `windows checks passed` verdict; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict.
|
||||
|
||||
`windows-coverage` runs [in-job partitioned coverage](../../../../scripts/coverage-partitions.ts) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. The observational step reuses the successful build, retains the distinct MPA documentation build, starts independent static gates together, and uses CI worker defaults for `publint`, with an eight-worker override on shared self-hosted runners. Its built-bin smoke starts only after every other observational gate settles, including failed gates. The ready aggregate requires a successful prior workspace build; it removes only that build and its internal dependency edges from the shared internal inventory. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
|
||||
|
||||
@@ -36,7 +36,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Make every native Windows result a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for coverage and the duplicated observational inventory. Requiring the build and targeted native-process suite retains fast native correctness signals while the other results remain automatic.
|
||||
**Make every native Windows result a dependency of `windows checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for coverage and the duplicated observational inventory. Requiring the build and targeted native-process suite retains fast native correctness signals while the other results remain automatic.
|
||||
|
||||
**Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken.
|
||||
|
||||
@@ -50,7 +50,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor
|
||||
|
||||
## Consequences
|
||||
|
||||
Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `all checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure.
|
||||
Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `windows checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure.
|
||||
|
||||
Every pull request receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. Observational checks reuse the successful build workspace, avoiding a second checkout, dependency installation, and workspace build. Their sequential position extends the build job and can delay the required verdict; it also separates diagnostic processes from compilation. The ready and master aggregates share one internal diagnostic inventory; master retains its own workspace build and full checks.
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布
|
||||
|
||||
[ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](../../../../.github/workflows/ci-master.yml),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
|
||||
|
||||
每个拉取请求都会启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。
|
||||
每个拉取请求都会在独立的 [CI Windows 工作流](../../../../.github/workflows/ci-windows.yml)中启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。GitHub 只有在一次运行的所有作业结束后才会重新运行失败的作业,因此独立工作流让维护者能在 Windows 作业仍在运行时重新运行失败的 Linux 作业,反之亦然。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。
|
||||
|
||||
`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。
|
||||
`windows-build` 与 `windows-native-tests` 是必需判定 `windows checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。
|
||||
|
||||
`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](../../../../scripts/coverage-partitions.ts):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。观测步骤复用成功的构建,保留独立的 MPA 文档构建,一起启动相互独立的静态门禁,并让 `publint` 使用 CI worker 默认值,在共享自托管运行器上覆盖为 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动,包括失败的门禁。ready 聚合要求此前已成功构建工作区;它仅从内部共用清单中移除该构建及其内部依赖边。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
|
||||
|
||||
@@ -36,7 +36,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**让每项原生 Windows 结果都成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待覆盖率和重复的观测性清单。要求构建与定向原生进程套件通过,可以保留快速的原生正确性信号,同时继续自动产生其他结果。
|
||||
**让每项原生 Windows 结果都成为 `windows checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待覆盖率和重复的观测性清单。要求构建与定向原生进程套件通过,可以保留快速的原生正确性信号,同时继续自动产生其他结果。
|
||||
|
||||
**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。
|
||||
|
||||
@@ -50,7 +50,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持
|
||||
|
||||
## 后果
|
||||
|
||||
Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。
|
||||
Wine 提供合并后的工具链证据。`windows checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。
|
||||
|
||||
每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。观测检查复用成功构建的工作区,省去第二次检出、依赖安装和工作区构建。顺序执行会延长构建作业,并可能延迟必需判定;它也让诊断进程与编译分开运行。ready 与 master 聚合共用一份内部诊断清单;master 保留自己的工作区构建和完整检查。
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 81d36185575e6d77
|
||||
zh: 19003ab154d93394
|
||||
/agent-note-windows-python-runtime-ci-stays-on-github-hosted-windows/decision:
|
||||
en: 1169dd58149ad6b4
|
||||
zh: aeebaf9ca8769de6
|
||||
en: 68aff432bf30d821
|
||||
zh: 6633d254904a39f6
|
||||
/agent-note-windows-python-runtime-ci-stays-on-github-hosted-windows/alternatives-considered:
|
||||
en: 817e77f011ea35c8
|
||||
zh: 2087bc653620a60f
|
||||
|
||||
@@ -10,7 +10,7 @@ The Windows x64 target in [build-exe-for-python-sdk.yml](../../../../.github/wor
|
||||
|
||||
## Decision
|
||||
|
||||
The Windows x64 target always uses its hosted `matrix.runner` — `windows-2025` for pull-request CI — with the standard setup-python toolchain, the pnpm cache restore, and the pkg cache. The failover selector, the job-private Python setup step, the self-hosted dependency install and post-step cleanup, the private setup script, and the routing spec from #3629 are removed. `DSH_CI_FAILOVER_WINDOWS=selfhosted` again retargets only the native Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml); the [failover runbook](2026-07-26-ci-failover-runbook.md) and [python/development.md](../../../../python/development.md) describe hosted-only runtime builds. The migration's UTF-8 mode exports existed because the persistent host used a GBK default code page; hosted images provide the locale the lane previously ran under.
|
||||
The Windows x64 target always uses its hosted `matrix.runner` — `windows-2025` for pull-request CI — with the standard setup-python toolchain, the pnpm cache restore, and the pkg cache. The failover selector, the job-private Python setup step, the self-hosted dependency install and post-step cleanup, the private setup script, and the routing spec from #3629 are removed. `DSH_CI_FAILOVER_WINDOWS=selfhosted` again retargets only the native Windows jobs in [ci-windows.yml](../../../../.github/workflows/ci-windows.yml); the [failover runbook](2026-07-26-ci-failover-runbook.md) and [python/development.md](../../../../python/development.md) describe hosted-only runtime builds. The migration's UTF-8 mode exports existed because the persistent host used a GBK default code page; hosted images provide the locale the lane previously ran under.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
Windows x64 目标始终使用托管的 `matrix.runner`——PR CI 为 `windows-2025`——配以标准 setup-python 工具链、pnpm 缓存恢复与 pkg 缓存。来自 #3629 的故障切换选择器、作业私有 Python 准备步骤、自托管依赖安装与后置清理、私有准备脚本及路由测试均被移除。`DSH_CI_FAILOVER_WINDOWS=selfhosted` 再次只重定向 [ci.yml](../../../../.github/workflows/ci.yml) 中的原生 Windows 作业;[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)与 [python/development.zh.md](../../../../python/development.zh.md) 描述仅托管的 runtime 构建。迁移中的 UTF-8 模式导出之所以存在,是因为持久主机使用 GBK 默认代码页;托管镜像提供该通道此前运行的区域设置。
|
||||
Windows x64 目标始终使用托管的 `matrix.runner`——PR CI 为 `windows-2025`——配以标准 setup-python 工具链、pnpm 缓存恢复与 pkg 缓存。来自 #3629 的故障切换选择器、作业私有 Python 准备步骤、自托管依赖安装与后置清理、私有准备脚本及路由测试均被移除。`DSH_CI_FAILOVER_WINDOWS=selfhosted` 再次只重定向 [ci-windows.yml](../../../../.github/workflows/ci-windows.yml) 中的原生 Windows 作业;[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)与 [python/development.zh.md](../../../../python/development.zh.md) 描述仅托管的 runtime 构建。迁移中的 UTF-8 模式导出之所以存在,是因为持久主机使用 GBK 默认代码页;托管镜像提供该通道此前运行的区域设置。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 43ecfec1601f933e
|
||||
zh: 3db5365403ffeac5
|
||||
/agent-note-blacksmith-as-a-failover-leg-for-ci-pools/decision:
|
||||
en: fac195b5006faa2d
|
||||
zh: 7c0ea86638c54e95
|
||||
en: 667aae1665477840
|
||||
zh: ff95c9c5fd9d1793
|
||||
/agent-note-blacksmith-as-a-failover-leg-for-ci-pools/alternatives-considered:
|
||||
en: a605440fed0ccc2a
|
||||
zh: 621b35ad2850ca1b
|
||||
|
||||
@@ -12,7 +12,7 @@ The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterp
|
||||
|
||||
`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at each job's configured vCPU size; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default.
|
||||
|
||||
Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the three Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too.
|
||||
Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, and the `all-checks-passed` verdict in [ci.yml](../../../../.github/workflows/ci.yml), plus the three Windows jobs and the `windows-checks-passed` verdict in [ci-windows.yml](../../../../.github/workflows/ci-windows.yml), plus the `python-checks-passed` verdict in [ci-python.yml](../../../../.github/workflows/ci-python.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (the `python-runtime` jobs in `ci-python.yml` and `ci-master.yml`) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too.
|
||||
|
||||
Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按各作业配置的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。
|
||||
|
||||
`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与三个 Windows 作业,使用该工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。
|
||||
`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿与 `all-checks-passed` 判定作业,以及 [ci-windows.yml](../../../../.github/workflows/ci-windows.yml) 中三个 Windows 作业与 `windows-checks-passed` 判定作业,以及 [ci-python.yml](../../../../.github/workflows/ci-python.yml) 中的 `python-checks-passed` 判定作业,使用这些工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci-python.yml` 与 `ci-master.yml` 中的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。
|
||||
|
||||
其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: dc43ba84345e1405
|
||||
zh: dbc03572ab7710d2
|
||||
/agent-note-ship-optional-bundles-with-the-installation/decision:
|
||||
en: 1362533f342b31f4
|
||||
zh: 90689262acefb43d
|
||||
en: 64a2cb7602842c44
|
||||
zh: bfcb3ef4a81773b4
|
||||
/agent-note-ship-optional-bundles-with-the-installation/alternatives-considered:
|
||||
en: 95b48b61757f1f13
|
||||
zh: e9fb2d6ea492a4bc
|
||||
|
||||
@@ -10,7 +10,7 @@ The Web plugin page manages only the bundles a person installed into the profile
|
||||
|
||||
## Decision
|
||||
|
||||
The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page presents offered optional bundles in its Official group, tagged experimental when the package is experimental, ahead of the profile's own installed bundles. The [persistent-terminal bundle](../../../../packages/experimental/terminal-bundle/README.md) remains available to the Host but has no page switch, even when enabled; its existing profile selection remains intact.
|
||||
The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/official-bundles/src/bundles.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page presents offered optional bundles in its Official group, tagged experimental when the package is experimental, ahead of the profile's own installed bundles. The [persistent-terminal bundle](../../../../packages/experimental/terminal-bundle/README.md) remains available to the Host but has no page switch, even when enabled; its existing profile selection remains intact.
|
||||
|
||||
Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样
|
||||
|
||||
## 决策
|
||||
|
||||
启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页在「官方」分组展示可供选择的可选组合包,属于实验包的带实验性标签,排在 profile 自己已安装的组合包之前。[持久终端组合包](../../../../packages/experimental/terminal-bundle/README.zh.md)仍可供 Host 使用,但即使已启用也不提供页面开关;已有的 profile 选择保持不变。
|
||||
启动器在 `OPTIONAL_BUNDLES`(`packages/boot/official-bundles/src/bundles.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页在「官方」分组展示可供选择的可选组合包,属于实验包的带实验性标签,排在 profile 自己已安装的组合包之前。[持久终端组合包](../../../../packages/experimental/terminal-bundle/README.zh.md)仍可供 Host 使用,但即使已启用也不提供页面开关;已有的 profile 选择保持不变。
|
||||
|
||||
默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: e5c984fa32cc5e69
|
||||
zh: 25d5742b2106d830
|
||||
/agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision:
|
||||
en: 26a903fdfeffcea7
|
||||
zh: f6ea094ec32a7b14
|
||||
en: 8353d57b20ca01c3
|
||||
zh: e0b74906846d759c
|
||||
/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; the two-target Python runtime matrix starts after the keyless Python SDK job succeeds. Four Linux lanes, the Python SDK, three Windows lanes, and one compatibility job permit at most nine worker jobs initially. Once the SDK job finishes, the remaining seven independent lanes, one compatibility job, and two runtime targets permit at most ten. The runtime plan and wheel stages precede those targets and each use one job. This permits runtime validation to overlap the Node 24.9 build within the original ten-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order.
|
||||
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.
|
||||
|
||||
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;双目标 Python runtime 矩阵在 keyless Python SDK job 成功后启动。四个 Linux lane、Python SDK、三个 Windows lane 和一个 compatibility job,初始最多占用九个 worker job。SDK job 完成后,剩余七个独立 lane、一个 compatibility job 和两个 runtime target,最多占用十个 job。runtime plan 与 wheel 阶段先于这些 target,各占一个 job。这允许 runtime 验证与 Node 24.9 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。
|
||||
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 顺序。
|
||||
|
||||
每个 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 的峰值。
|
||||
|
||||
|
||||
+18
-18
@@ -2,21 +2,21 @@
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.md
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run:
|
||||
en: a1f0f9fd657c7d35
|
||||
zh: e8fa02fb5bde6075
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/problem:
|
||||
en: 8e854872edfc0729
|
||||
zh: 6f3ed495fcb690a4
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/decision:
|
||||
en: 03de94946d7532d7
|
||||
zh: cf2f585f715d3295
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/verification:
|
||||
en: 8298cff64cce6467
|
||||
zh: a121d509c2302c00
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/alternatives-considered:
|
||||
en: 197255cd82aabef7
|
||||
zh: dc03f4bf77150f22
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/consequences:
|
||||
en: 25e1ac1c8cab089a
|
||||
zh: 84e10cccb1b9eda7
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app:
|
||||
en: 1ea2e345d6bc1058
|
||||
zh: 65f872a0606660b8
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/problem:
|
||||
en: f2dc92b64d32bd68
|
||||
zh: 7f24eaaa49a5776f
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/decision:
|
||||
en: 5f7ebd56d43445cf
|
||||
zh: 869e144309b85c28
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/verification:
|
||||
en: c80da76f52dac191
|
||||
zh: 1f7f8458211e1f5b
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/alternatives-considered:
|
||||
en: e3b37e68b3e76d05
|
||||
zh: bb6c917c3fc6b685
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/consequences:
|
||||
en: 79a97b43e268dd04
|
||||
zh: b85691eac68c313f
|
||||
+15
-11
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Weighted approval keeps its commit status gate and adds a decision check run
|
||||
# Agent Note: Weighted approval keeps its commit status gate and publishes the decision label from an App
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,32 +6,36 @@ English | [中文](2026-10-20-weighted-approval-status-gate-with-check-run-label
|
||||
|
||||
## Problem
|
||||
|
||||
The `weighted approval` commit status is the record this repository's branch rules require, and below the approval threshold it stays `pending`. A commit status has no action-required state, so readers cannot tell an approval wait from running work. Moving the gate to a check run does not work: after that change, the check run completed as `success` on the pull-request head and both the REST API and `gh pr checks` listed it, while the merge box kept reporting the same `weighted approval decision` context as `Expected — Waiting for status to be reported`, and two pull requests needed an administrator bypass. That change was reverted.
|
||||
The `weighted approval` commit status is the record this repository's branch rules require, and below the approval threshold it stays `pending`. A commit status has no action-required state, so readers cannot tell an approval wait from running work.
|
||||
|
||||
A check run carries that state, but GitHub attaches a check run created through the API to the earliest Actions check suite on the head, and a check suite created by a `pull_request_target` run is not associated with the pull request. Measured on 2026-10-20 and 2026-10-21: when the earliest suite belonged to a `pull_request` workflow the merge box listed the label, and when it belonged to the `pull_request_target` publisher it listed none, while the Checks page listed it either way; which suite is earliest is a race among the workflows a push starts. Moving the gate to a required check run published with the job token hit the same race: the merge box reported the context as `Expected — Waiting for status to be reported` while the REST API and `gh pr checks` listed it complete, so two pull requests needed an administrator bypass; that change was reverted.
|
||||
|
||||
## Decision
|
||||
|
||||
The publisher keeps writing the `weighted approval` commit status exactly as before: `pending` while the policy is unmet, `success` when it holds, and `error` when setup or evaluation fails. Branch rules keep requiring that status. That behavior, including keeping an unmet policy `pending`, is what the [archived decision](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md) records; this change adds the label beside it instead of changing the gate.
|
||||
|
||||
The publisher additionally writes a `weighted approval decision` check run for the same evaluation: `action_required` while the policy is unmet, `success` when it holds, and `failure` when setup or evaluation fails. The check run's title carries the score or the blocker. Branch rules do not require it, so it never blocks a merge; it names the blocked state beside the status that does.
|
||||
The publisher writes the `weighted approval decision` check run with a token minted from a dedicated approval App (`actions/create-github-app-token` with `vars.DSH_APPROVAL_APP_CLIENT_ID`, `secrets.DSH_APPROVAL_APP_PRIVATE_KEY`, and `vars.DSH_APPROVAL_APP_SLUG`, scoped to `checks: write`). The check suite then belongs to the App and is associated with the pull-request head, so the merge box lists the label without depending on which Actions workflow started first. The conclusion is `action_required` while the policy is unmet, `success` when it holds, and `failure` when setup or evaluation fails; the title carries the score or the blocker. Branch rules do not require the check run, so it never blocks a merge.
|
||||
|
||||
A head keeps one GitHub Actions run with the check-run name: the publisher updates the head's existing run and creates one only when none exists. The setup phase creates the run as `in_progress` on a head without one and deals with a published success by rewriting its conclusion to `action_required`, because GitHub accepts but does not apply a return to `in_progress`. A failed check-run write is logged and never changes the status, so a token without the checks permission, or a check-run API failure, leaves the gate as the status wrote it. The publisher identifies its own runs by the `external_id` marker it sets on creation, which excludes check runs that workflow jobs produce for a same-named job. It revokes a published `success` before dependency setup, because GitHub accepts but does not apply a return to `in_progress`.
|
||||
The status and the check run are written through separate API clients: the status gate uses the job token, and only the check-run calls use the App token. A failed App token step or check-run write is logged and never changes the status. When the token or the slug is unavailable the publisher keeps the job token and updates `github-actions` runs only, so the gate keeps working and the label stays on the Checks page; the job token cannot update an App run, so such a head keeps the App run it already has.
|
||||
|
||||
The publisher selects the check runs created by the publishing token by `app.slug`: the App token matches the App's runs and the job token matches `github-actions` runs, because neither token can update a run the other created. It updates every selected run of that app and creates one only when none exists; job-token runs written by earlier evaluations stay untouched and can coexist with the App run on a head created before the App was configured. The setup phase creates the run as `in_progress` on a head without one and deals with a published success by rewriting its conclusion to `action_required`, because GitHub accepts but does not apply a return to `in_progress`. Runs the publisher creates carry the `external_id` marker it sets on creation.
|
||||
|
||||
## Verification
|
||||
|
||||
[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the unchanged status states, the check-run conclusion for a met and an unmet policy, the phase writes, revoking a published success through its conclusion, one run per head, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` and `statuses: write` permissions and the setup phase commands.
|
||||
[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the unchanged status states, the check-run conclusion for a met and an unmet policy, the phase writes, revoking a published success through its conclusion, one run per head, invalid list responses, the status fallback when the label write fails, and the split between the status client and the check-run client. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` and `statuses: write` permissions, the App token step and its single secret reference, the `DECISION_TOKEN` wiring, and the setup phase commands.
|
||||
|
||||
The check-run limit was measured on 2026-10-20 with the reverted attempt on this repository: the `weighted approval decision` check run completed as `success` on the pull-request head, the REST API and `gh pr checks` listed it, and the merge box kept reporting the same context as expected.
|
||||
The check-run association limit was measured on 2026-10-20 with the reverted and the previous attempts on this repository: a required check run published with the job token stayed `Expected` in the merge box, and a non-required check run published with the job token was absent from the merge box's check list while the Checks page listed it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Require the check run instead of the commit status.** Measured above: the ruleset does not match a check run published through the default-branch workflow's token, so the gate cannot be satisfied.
|
||||
**Publish the label with the job token and accept the Checks page only.** That is the state this change replaces; the merge box never lists the label, so the conversation page shows only the pending status.
|
||||
|
||||
**Publish the commit status as `failure` while the policy is unmet.** The status would complete, but this changes the required record's meaning and would report an unmet, reversible condition as a malfunction.
|
||||
**Require the App check run instead of the commit status.** The App check run is associated with the pull request, so it could carry the gate. That needs a ruleset change to the App's integration and a migration for open heads; this change keeps the proven status gate and only moves the label onto the App.
|
||||
|
||||
**Publish the commit status as `failure` while the policy is unmet.** The status would complete, but this changes the required record's meaning and reports an unmet, reversible condition as a malfunction.
|
||||
|
||||
**Publish only the check run and leave the status untouched.** The status would keep its last state from an earlier evaluation, so the gate and the label could disagree.
|
||||
|
||||
**Publish the required check run from a dedicated GitHub App.** A check run whose app the ruleset names would let the required entry carry the action-required conclusion itself. The repository has no App with the checks permission; with one, the label could move onto the required record and the status could be retired.
|
||||
|
||||
## Consequences
|
||||
|
||||
A job that ends between the `pending` write and the fallback step, such as a lost runner or a cancellation that skips the final step, leaves the status `pending`; that is the original symptom of this status, not a regression from the added label. While reviewers are pending, the merge box reports `weighted approval` as pending (the gate) and the `weighted approval decision` entry as `action_required`, with the score or blocker in its title; `gh pr checks` lists both. A published success is revoked by rewriting the check run's conclusion, and the status follows the unchanged policy states.
|
||||
The merge box now lists `weighted approval` (the gate, pending while reviewers are pending) and `weighted approval decision` (`action_required`, with the score or blocker in its title). If the App token cannot be minted, the label falls back to the job token and stays on the Checks page only; the gate is unaffected. A job that ends between the `pending` write and the fallback step, such as a lost runner or a cancellation that skips the final step, leaves the status `pending`; that is the original symptom of this status, not a regression.
|
||||
+14
-10
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Weighted approval 保留其 commit status 门禁并新增决策 check run
|
||||
# Agent Note: Weighted approval 保留 commit status 门禁,并由 App 发布决策标签
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,32 +6,36 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
`weighted approval` commit status 是本仓库分支规则要求的记录,在批准阈值下方时保持 `pending`。commit status 没有 action-required 状态,读者无法区分等待批准与正在运行的工作。把门禁改成 check run 并不可行:该改动后 check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一上下文报告为 `Expected — Waiting for status to be reported`,两个 PR 需要管理员绕过才能合并。该改动已回退。
|
||||
`weighted approval` commit status 是本仓库分支规则要求的记录,在批准阈值下方时保持 `pending`。commit status 没有 action-required 状态,读者无法区分等待批准与正在运行的工作。
|
||||
|
||||
check run 可以承载这个状态,但 GitHub 会把通过 API 创建的 check run 挂到该 head 上最早的 Actions check suite,而 `pull_request_target` 的 run 创建的 check suite 不与该 PR 关联。2026-10-20 与 2026-10-21 实测:最早 suite 属于 `pull_request` workflow 时合并框会列出该标签,属于 `pull_request_target` 的 publisher 时合并框一条都不列,而 Checks 页两种情况下都能列出;哪个 suite 最早是同一次 push 触发的多个 workflow 之间的竞态。把门禁改成用 job token 发布的必需 check run 也撞上同一个竞态:合并框把该上下文报告为 `Expected — Waiting for status to be reported`,而 REST API 与 `gh pr checks` 都显示已完成,两个 PR 需要管理员绕过,该改动已回退。
|
||||
|
||||
## 决定
|
||||
|
||||
publisher 继续完全按原样写 `weighted approval` commit status:策略未满足时为 `pending`,满足时为 `success`,安装或评估失败时为 `error`。分支规则继续要求该 status。该行为——包括未满足的策略保持 `pending`——正是[已归档决定](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md)记录的;本次变更在该门禁旁边新增标签,而不改变门禁。
|
||||
|
||||
publisher 为同一次评估额外写一个 `weighted approval decision` check run:策略未满足时为 `action_required`,满足时为 `success`,安装或评估失败时为 `failure`。check run 的标题携带分数或 blocker。分支规则不要求它,因此它从不阻止合并;它在会阻止合并的那条 status 旁边命名被阻止的状态。
|
||||
publisher 用从专用批准 App 铸造的 token(`actions/create-github-app-token`,使用 `vars.DSH_APPROVAL_APP_CLIENT_ID`、`secrets.DSH_APPROVAL_APP_PRIVATE_KEY` 与 `vars.DSH_APPROVAL_APP_SLUG`,权限限定为 `checks: write`)写 `weighted approval decision` check run。check suite 因此归 App 所有并与 PR head 关联,合并框会列出该标签,不再取决于哪个 Actions workflow 先启动。策略未满足时结论为 `action_required`,满足时为 `success`,安装或评估失败时为 `failure`;标题携带分数或 blocker。分支规则不要求该 check run,因此它从不阻止合并。
|
||||
|
||||
每个 head 只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建。setup 阶段对没有 run 的 head 以 `in_progress` 创建,并把已发布的 success 的 conclusion 改写为 `action_required`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。check run 写入失败会被记录,且从不改变 status,因此没有 checks 权限的 token 或 check-run API 失败时,门禁保持 status 写入的结果。publisher 用创建时设置的 `external_id` 标记识别自己的 run,从而排除 workflow job 为同名 job 产生的 check run。它在依赖安装前撤销已发布的 `success`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。
|
||||
status 与 check run 通过两个独立的 API client 写入:status 门禁使用 job token,只有 check-run 调用使用 App token。token 步骤失败或 check-run 写入失败会被记录,且从不改变 status,因此 App 权限缺失时门禁照常工作,标签只出现在 Checks 页。
|
||||
|
||||
publisher 按 `app.slug` 选择由当前发布 token 创建的 run:App token 只匹配 App 的 run,job token 只匹配 `github-actions` 的 run,因为两种 token 都无法更新对方创建的 run。它更新所选 app 的所有同名 run,仅在不存在时创建;App 配置之前由 job token 写入的 run 保持不变,可能与 App run 在同一个 head 上共存。setup 阶段对没有 run 的 head 以 `in_progress` 创建,并把已发布的 success 的 conclusion 改写为 `action_required`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。publisher 创建的 run 带创建时设置的 `external_id` 标记。
|
||||
|
||||
## 验证
|
||||
|
||||
[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定未改动的 status 状态、策略满足与未满足两种情况下 check run 的 conclusion、阶段写入、通过 conclusion 撤销已发布的成功、每个 head 只有一个 run,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 与 `statuses: write` 权限以及 setup 阶段命令。
|
||||
[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定未改动的 status 状态、策略满足与未满足两种情况下 check run 的 conclusion、阶段写入、通过 conclusion 撤销已发布的成功、每个 head 只有一个 run、无效的列表响应、标签写入失败时 status 的兜底,以及 status client 与 check-run client 的分离。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 与 `statuses: write` 权限、App token 步骤及其唯一的密钥引用、`DECISION_TOKEN` 的接线,以及 setup 阶段命令。
|
||||
|
||||
check run 的限制于 2026-10-20 在本仓库用被回退的尝试实测:`weighted approval decision` check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一个 `weighted approval decision` 上下文报告为 expected。
|
||||
check run 的关联限制于 2026-10-20 在本仓库用回退的尝试与上一版尝试实测:用 job token 发布的必需 check run 在合并框里保持 `Expected`;用 job token 发布的非必需 check run 不在合并框的检查列表中,而 Checks 页能列出它。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**把 check run 作为必需检查。** 见上面的实测:ruleset 不匹配默认分支 workflow 的 token 发布的 check run,门禁无法满足。
|
||||
**用 job token 发布标签、只接受 Checks 页可见。** 这正是本次变更要替换的状态;合并框永远不列该标签,会话页只显示 pending 的 status。
|
||||
|
||||
**把 App check run 设为必需检查来替代 commit status。** App check run 已关联 PR,可以承载门禁。但那需要把 ruleset 改成该 App 的 integration 并为开放 head 做迁移;本次保留已验证的 status 门禁,只把标签移到 App。
|
||||
|
||||
**策略未满足时把 commit status 发布为 `failure`。** status 会完成,但改变了必需记录的含义,并把尚未满足且可撤销的条件报告为故障。
|
||||
|
||||
**只发布 check run 而不动 status。** status 会保留上一次评估的状态,门禁与标签可能不一致。
|
||||
|
||||
**由专用 GitHub App 发布必需 check run。** 由 ruleset 指定 app 的 check run 可以让必需条目本身携带 action-required 结论。本仓库没有具备 checks 权限的 App;有了之后可以把标签移到必需记录上并停用 status。
|
||||
|
||||
## 后果
|
||||
|
||||
job 在 `pending` 写入与兜底步骤之间结束(例如 runner 丢失,或取消导致最后一步未执行)会让 status 保持 `pending`;这是该 status 既有的表现,不是新增标签带来的回归。评审未完成时,合并框把 `weighted approval` 报告为 pending(门禁),并把 `weighted approval decision` 条目报告为 `action_required`,标题里是分数或 blocker;`gh pr checks` 会列出两者。已发布的 success 通过改写 check run 的 conclusion 撤销,status 继续按未改动的策略状态变化。
|
||||
合并框现在同时列出 `weighted approval`(门禁,评审未完成时为 pending)与 `weighted approval decision`(`action_required`,标题里是分数或 blocker)。App token 步骤失败或 check-run 写入失败会被记录,且从不改变 status。缺少 token 或 slug 时 publisher 保留 job token、只更新 `github-actions` 的 run,因此门禁照常工作、标签留在 Checks 页;job token 无法更新 App run,这类 head 会保留已有的 App run。job 在 `pending` 写入与兜底步骤之间结束(例如 runner 丢失,或取消导致最后一步未执行)会让 status 保持 `pending`;这是该 status 既有的表现,不是新增标签带来的回归。
|
||||
+2
-2
@@ -21,8 +21,8 @@
|
||||
en: 0f98f5b5f78b5603
|
||||
zh: 95ee8bafa66d6b1b
|
||||
/agent-note-installed-wheel-python-runtime-pull-request-validation/decision/required-targets:
|
||||
en: 7a33cde000508475
|
||||
zh: 142fb5ec6b59a280
|
||||
en: ff9f43ef5d3480c8
|
||||
zh: eb8d94581522f1ec
|
||||
/agent-note-installed-wheel-python-runtime-pull-request-validation/existing-decisions-and-supersession:
|
||||
en: 04aa439d4e607324
|
||||
zh: f4c1d24b6a418cc4
|
||||
|
||||
@@ -30,7 +30,7 @@ Fork and Dependabot pull requests never receive the repository secret. Their nat
|
||||
|
||||
### Required targets
|
||||
|
||||
The pull-request `python-runtime` job calls the reusable builder for Linux x64 and Windows x64; master pushes select Linux arm64 and both macOS architectures under the [master-only platform policy](../../../../.github/workflows/ci-master.yml). Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. The [sdk-runtime README](../../../../python/sdk-runtime/README.md) owns the Windows target and its PowerShell-specific minimal snapshot.
|
||||
The pull-request `python-runtime` job in CI Python calls the reusable builder for Linux x64 and Windows x64; master pushes select Linux arm64 and both macOS architectures under the [master-only platform policy](../../../../.github/workflows/ci-master.yml). Its aggregate result is a dependency of CI Python's required verdict, `python checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. The [sdk-runtime README](../../../../python/sdk-runtime/README.md) owns the Windows target and its PowerShell-specific minimal snapshot.
|
||||
|
||||
## Existing decisions and supersession
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生
|
||||
|
||||
### 必需目标
|
||||
|
||||
拉取请求的 `python-runtime` job 针对 Linux x64 与 Windows x64 调用可复用构建器;master 推送根据[仅 master 平台策略](../../../../.github/workflows/ci-master.yml)选择 Linux arm64 与两种 macOS 架构。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。[sdk-runtime README](../../../../python/sdk-runtime/README.zh.md) 负责 Windows 目标及其 PowerShell 专属极简快照。
|
||||
拉取请求的 `python-runtime` job 在 CI Python 中针对 Linux x64 与 Windows x64 调用可复用构建器;master 推送根据[仅 master 平台策略](../../../../.github/workflows/ci-master.yml)选择 Linux arm64 与两种 macOS 架构。其聚合结果是 CI Python 必需判定 `python checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。[sdk-runtime README](../../../../python/sdk-runtime/README.zh.md) 负责 Windows 目标及其 PowerShell 专属极简快照。
|
||||
|
||||
## Existing decisions and supersession
|
||||
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
---
|
||||
name: dsh-session-projection
|
||||
description: Use when creating, extending, or reviewing a DeepSeek Harness session projection, or migrating a fold helper to one.
|
||||
description: Use when creating, extending, or reviewing a DeepSeek Harness session projection, or migrating session-derived recovery state to one.
|
||||
---
|
||||
|
||||
# DeepSeek Harness Session Projections
|
||||
# Session Projection Rules
|
||||
|
||||
Follow the [projection API](../../../packages/session/session-projection/README.md) for registration and lifecycle details.
|
||||
## Summary
|
||||
|
||||
1. Decide whether to extend an existing projection or create one; check whether an existing owner already maintains the needed state.
|
||||
2. Follow the README's [service ownership rules](../../../packages/session/session-projection/README.md#register-and-read): keep projection definitions and handles private to the owning service's implementation, and expose domain methods to consumers.
|
||||
3. Require `sessionProjections` when the service needs projected state to function. Optional registration is for contributions the service can operate without.
|
||||
4. Evaluate retained state size as the session grows. Do not accumulate state proportional to session length, including collections of event sequence numbers. Growth is acceptable only when slow enough to remain negligible. When the data would keep growing, consider incremental loading instead of retaining the complete state.
|
||||
5. `apply` is synchronous; historical reads belong in asynchronous consumers. When later reads need event payloads, consider retaining sequence numbers within rule 4's state-size limits and letting consumers resolve them asynchronously.
|
||||
6. When replacing a helper such as `foldSessionTitle` with a projection, remove the superseded helper.
|
||||
7. After migrating to Session persistence, stop using `fold` in names and descriptions of the migrated logic.
|
||||
8. Do not add calls to deprecated synchronous readers (`snapshotEvents`, `eventAt`, `ownEvents`) or wrappers exposing the same access ([decision](../../notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Assume the full event log is not resident in memory and random reads may require asynchronous disk access.
|
||||
9. Consider state size, replay cost, and per-event work; add complexity only for a concrete performance need.
|
||||
10. Asynchronous `register()` is usually called in `[Service.init]`. If event listeners are registered afterward, check initialization ordering: when needed, install listeners before awaiting registration; if a listener needs the returned handle, await its readiness in a callback that supports asynchronous waiting. Otherwise, an event may fire before the listener is installed and be missed. Determine whether this race exists from the actual initialization flow and event trigger paths; do not move listeners earlier or add waits mechanically.
|
||||
Keep recoverable session facts in Projection and expose them through their owning services. Read the [API and lifecycle](../../../packages/session/session-projection/README.md) for the supported preparation, registration, read, and cleanup contracts.
|
||||
|
||||
1. Put all persisted or restorable session-derived state in Projection, including host-only state. Reuse an existing owner when suitable. Keep external resources and process-scoped ownership outside persisted state. Persist facts, not registration handles, promises, or runtime resources.
|
||||
|
||||
2. Do not expose projection definitions or handles as production read APIs. Keep the ready handle private; consumers use the owning service's domain methods. A handle is a process-local reader, recreated during activation. Require `sessionProjections` when the service depends on it; optional registration is only for optional contributions. Do not hide a missing required dependency with another recovery path.
|
||||
|
||||
3. State recovery through `init` and `apply` is synchronous and deterministic. Do not perform I/O or trigger Cordis hooks, models, or tools. Runtime hooks own those effects. Replay must not repeat them. Return the same state reference for unrelated events. Keep views synchronous and preserve their references when visible values do not change.
|
||||
|
||||
4. Persist JSON state that its schema accepts. Follow the README's state-version and checkpoint-watermark rules. Restoring a checkpoint and replaying its suffix must match full replay. Change the version when state fields or recovery semantics change.
|
||||
|
||||
5. Prefer fixed-size or sublinear state. Growth is acceptable only when slow enough to remain negligible. Necessary linear state must have a current use and explicit memory, replay, and per-event costs. Do not retain history-sized indexes, including event sequence collections, by habit. Do not discard required data or change behavior to reduce size. Start with a complete implementation; optimize for a concrete need.
|
||||
|
||||
6. Caches must be discardable and rebuildable without changing behavior. A sequence reference alone is insufficient when a synchronous consumer needs the referenced payload. Store the required current value within rule 5, or prepare the payload through an explicit asynchronous path. Do not hide full-log reads in a cache miss. Do not add `snapshotEvents`, `eventAt`, `ownEvents`, or equivalent wrappers: history need not stay in memory. An async wrapper does not make synchronous history reads asynchronous. Existing host replay may remain for named supported callers, with an owner and removal condition; this does not permit [Client cache-miss full-log reads](../../../docs/defensive-patterns/conversation-and-session.md#cold-reads-are-the-last-resort). Remove superseded recovery helpers; cache reconstruction must not duplicate recovery rules.
|
||||
|
||||
7. Await required registration before exposing synchronous readers or installing callbacks that use its handle, usually in `[Service.init]`. Let registration rejection fail that activation. Events can arrive during the await; install listeners earlier only when needed to avoid missed events. Await only in callbacks that support it. Do not move listeners or add waits mechanically. Keep fiber cleanup ownership, await explicit disposal, and do not create effects after the owner is disposed.
|
||||
|
||||
8. Keep registration readiness and Session recovery separate. The asynchronous Session-loading or preparation owner completes historical recovery before it publishes the Session to synchronous readers. Use the registry's supported lifecycle; do not add hydration or hidden I/O to domain getters. Recovery prepares state; getters read it. Preserve the registry's error scope for each unit and Session.
|
||||
|
||||
9. Identify all official consumers before switching a read path, including plugins, CLI/GUI, and both SDKs. Preserve synchronous callers until they migrate explicitly; a Promise cannot implement a synchronous API. A new required service or activation step needs consumer updates and an upgrade guide. Keep compatibility tied to actual supported callers, not test-only compositions.
|
||||
|
||||
10. Verify values, notifications, error scope and timing, and recovery through affected real entry points. Keep existing behavior assertions. Do not move consumer-only errors into eager Projection processing. For lifecycle changes, test delayed registration, rejection, replacement, and disposal during an await. Use actual Session/store lifecycles and explicit barriers instead of partial Session mocks or microtask counts. Check error logs as well as released state. Recovery tests cover usable, missing, and incompatible checkpoints plus suffix replay, without runtime effects.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md — GitHub Actions
|
||||
|
||||
Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](workflows/ci-master.yml)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)).
|
||||
Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows pull-request jobs live in `ci-windows.yml` and Python pull-request jobs in `ci-python.yml`, so each PR workflow can be re-run independently; their checks feed the required `windows checks passed` and `python checks passed` verdicts, as Linux jobs in `ci.yml` feed `all checks passed`; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](workflows/ci-master.yml)). `ci.yml`, `ci-windows.yml`, and `ci-python.yml` are pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)).
|
||||
|
||||
The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes
|
||||
|
||||
## Approval scoring
|
||||
|
||||
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. The publisher also writes a `weighted approval decision` check run that reports the same decision as a conclusion — `action_required` while the policy is unmet, `success` when it holds, and `failure` on setup or evaluation failures — with the score or blocker in its title. Branch rules do not require that check run, so it never blocks a merge; it names the blocked state beside the status that gates the merge. A failed check-run write is logged and never changes the status.
|
||||
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. The publisher also writes a `weighted approval decision` check run that reports the same decision as a conclusion — `action_required` while the policy is unmet, `success` when it holds, and `failure` on setup or evaluation failures — with the score or blocker in its title. Branch rules do not require that check run, so it never blocks a merge; it names the blocked state beside the status that gates the merge. The check run is published with a token minted from a dedicated approval App (`DSH_APPROVAL_APP_CLIENT_ID`, `DSH_APPROVAL_APP_PRIVATE_KEY`, `DSH_APPROVAL_APP_SLUG`, scope `checks: write`): GitHub attaches a check run created with the job token to the earliest Actions check suite on the head, and when that suite belongs to this `pull_request_target` workflow the merge box omits it, so the App identity makes the association stable. The App publishes only runs whose `app.slug` is the App; job-token runs from earlier evaluations stay untouched and can coexist on a head. When the token or slug is missing, the publisher falls back to the job token and updates job-token runs only, which keeps the label on the Checks page. The App token step runs with `continue-on-error`, so a missing App configuration fails only that step and leaves the job-token fallback; a failed check-run write is logged by the publisher and never changes the status.
|
||||
|
||||
Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count.
|
||||
|
||||
@@ -44,7 +44,7 @@ Each sender contributes at most once, using their own fixed weight or production
|
||||
|
||||
## Security
|
||||
|
||||
All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision.
|
||||
All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code. Its only secret is the dedicated approval App private key, used to mint a token scoped to `checks: write` for the decision check run; the status gate keeps using the job token. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision.
|
||||
|
||||
Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
|
||||
|
||||
|
||||
@@ -308,10 +308,10 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
|
||||
|
||||
/**
|
||||
* Evaluate and publish the required commit status, publishing an error status when evaluation fails.
|
||||
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
|
||||
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, checkApi?: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, checkAppSlug?: string, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs; checkApi publishes the decision check run, defaulting to api, and checkAppSlug selects the runs that client can update.
|
||||
* @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
|
||||
*/
|
||||
export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
export async function runApprovalCheck({ event, policySource, api, checkApi = api, checkAppSlug = 'github-actions', runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
const pull = pullRequestFromEvent(event)
|
||||
await publishStatus(api, pull, 'pending', 'Evaluating approval points.', runUrl)
|
||||
let result
|
||||
@@ -334,7 +334,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
}
|
||||
} catch (error) {
|
||||
await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
|
||||
await tryPublishCheck(api, pull, 'failure', 'Approval evaluation failed.', runUrl, write)
|
||||
await tryPublishCheck(checkApi, pull, 'failure', 'Approval evaluation failed.', runUrl, write, checkAppSlug)
|
||||
throw error
|
||||
}
|
||||
write(result.authorCredit
|
||||
@@ -346,7 +346,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
|
||||
writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
|
||||
await publishStatus(api, pull, result.state, result.description, runUrl)
|
||||
await tryPublishCheck(api, pull, result.state === 'success' ? 'success' : 'action_required', result.description, runUrl, write)
|
||||
await tryPublishCheck(checkApi, pull, result.state === 'success' ? 'success' : 'action_required', result.description, runUrl, write, checkAppSlug)
|
||||
write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
|
||||
try {
|
||||
await requestDelegatedReviews(result, api, write)
|
||||
@@ -358,15 +358,15 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
|
||||
/**
|
||||
* Revoke a previous success before dependency installation, or report its failure.
|
||||
* @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, runUrl: string, phase: string, write?: (line: string) => void}} options Publication inputs.
|
||||
* @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, checkApi?: (path: string, options: object) => Promise<unknown>, checkAppSlug?: string, runUrl: string, phase: string, write?: (line: string) => void}} options Publication inputs; checkApi publishes the decision check run and checkAppSlug selects the runs it can update.
|
||||
* @returns {Promise<void>} Completion of the status write and the corresponding check-run write.
|
||||
*/
|
||||
export async function publishApprovalPhase({ event, api, runUrl, phase, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
export async function publishApprovalPhase({ event, api, checkApi = api, checkAppSlug = 'github-actions', runUrl, phase, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
const pull = pullRequestFromEvent(event)
|
||||
if (phase === 'pending') {
|
||||
await publishStatus(api, pull, 'pending', 'Preparing approval evaluation.', runUrl)
|
||||
try {
|
||||
await revokeApproval(api, pull, runUrl)
|
||||
await revokeApproval(checkApi, pull, runUrl, checkAppSlug)
|
||||
} catch (error) {
|
||||
write(`Decision check run not revoked; the commit status is unchanged: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`)
|
||||
}
|
||||
@@ -374,7 +374,7 @@ export async function publishApprovalPhase({ event, api, runUrl, phase, write =
|
||||
}
|
||||
if (phase === 'error') {
|
||||
await publishStatus(api, pull, 'error', 'Approval setup or evaluation failed.', runUrl)
|
||||
return tryPublishCheck(api, pull, 'failure', 'Approval setup or evaluation failed.', runUrl, write)
|
||||
return tryPublishCheck(checkApi, pull, 'failure', 'Approval setup or evaluation failed.', runUrl, write, checkAppSlug)
|
||||
}
|
||||
throw new Error('invalid approval setup phase')
|
||||
}
|
||||
@@ -486,20 +486,20 @@ async function reviewerPermission(api, repository, login) {
|
||||
// The required gate is the commit status; this check run reports the same decision as a conclusion
|
||||
// label and is not required. A failed label write is reported and never changes the gate, including
|
||||
// for a replayed workflow whose token has no checks permission.
|
||||
async function tryPublishCheck(api, pull, conclusion, title, runUrl, write) {
|
||||
async function tryPublishCheck(checkApi, pull, conclusion, title, runUrl, write, appSlug) {
|
||||
try {
|
||||
await publishCheck(api, pull, conclusion, title, runUrl)
|
||||
await publishCheck(checkApi, pull, conclusion, title, runUrl, appSlug)
|
||||
} catch (error) {
|
||||
write(`Decision check run not published; the commit status is unchanged: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function publishCheck(api, pull, conclusion, title, runUrl) {
|
||||
async function publishCheck(checkApi, pull, conclusion, title, runUrl, appSlug) {
|
||||
requireHttpsRunUrl(runUrl)
|
||||
const body = { status: 'completed', conclusion, details_url: runUrl, output: { title, summary: `${title} [Workflow run](${runUrl})` } }
|
||||
const runs = await decisionRuns(api, pull)
|
||||
const runs = await decisionRuns(checkApi, pull, appSlug)
|
||||
if (runs.length === 0) {
|
||||
await api(`/repos/${pull.repository}/check-runs`, {
|
||||
await checkApi(`/repos/${pull.repository}/check-runs`, {
|
||||
method: 'POST',
|
||||
body: { name: CHECK_NAME, head_sha: pull.headSha, external_id: CHECK_RUN_MARKER, ...body },
|
||||
})
|
||||
@@ -507,21 +507,21 @@ async function publishCheck(api, pull, conclusion, title, runUrl) {
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (!Number.isSafeInteger(run.id)) throw new Error('check run has no valid id')
|
||||
await api(`/repos/${pull.repository}/check-runs/${run.id}`, { method: 'PATCH', body })
|
||||
await checkApi(`/repos/${pull.repository}/check-runs/${run.id}`, { method: 'PATCH', body })
|
||||
}
|
||||
}
|
||||
|
||||
// A head without a decision starts in progress; a published success becomes action_required, so an
|
||||
// interrupted evaluation cannot leave a success that its current inputs no longer support.
|
||||
async function revokeApproval(api, pull, runUrl) {
|
||||
async function revokeApproval(checkApi, pull, runUrl, appSlug) {
|
||||
requireHttpsRunUrl(runUrl)
|
||||
const title = 'Preparing approval evaluation.'
|
||||
const output = { title, summary: `${title} [Workflow run](${runUrl})` }
|
||||
const runs = await decisionRuns(api, pull)
|
||||
const runs = await decisionRuns(checkApi, pull, appSlug)
|
||||
if (runs.length === 0) {
|
||||
await api(`/repos/${pull.repository}/check-runs`, {
|
||||
await checkApi(`/repos/${pull.repository}/check-runs`, {
|
||||
method: 'POST',
|
||||
body: { name: CHECK_NAME, head_sha: pull.headSha, status: 'in_progress', details_url: runUrl, output },
|
||||
body: { name: CHECK_NAME, head_sha: pull.headSha, external_id: CHECK_RUN_MARKER, status: 'in_progress', details_url: runUrl, output },
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -529,7 +529,7 @@ async function revokeApproval(api, pull, runUrl) {
|
||||
if (!Number.isSafeInteger(run.id)) throw new Error('check run has no valid id')
|
||||
// The label is not a gate; only a published success has to stop claiming the policy holds.
|
||||
if (run.conclusion !== 'success') continue
|
||||
await api(`/repos/${pull.repository}/check-runs/${run.id}`, {
|
||||
await checkApi(`/repos/${pull.repository}/check-runs/${run.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { status: 'completed', conclusion: 'action_required', details_url: runUrl, output },
|
||||
})
|
||||
@@ -537,13 +537,14 @@ async function revokeApproval(api, pull, runUrl) {
|
||||
}
|
||||
|
||||
// A completed check run never returns to in_progress: GitHub answers 200 and leaves it completed.
|
||||
async function decisionRuns(api, pull) {
|
||||
const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=100`)
|
||||
async function decisionRuns(checkApi, pull, appSlug) {
|
||||
const listed = await checkApi(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=100`)
|
||||
if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) {
|
||||
throw new Error('check-run list response is invalid or incomplete')
|
||||
}
|
||||
return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions'
|
||||
&& (run.external_id === CHECK_RUN_MARKER || run.external_id === ''))
|
||||
// Only runs created by the publishing token can be updated: the job token cannot patch an App run
|
||||
// and the App token cannot patch an Actions run, so the slug selects the runs this token owns.
|
||||
return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === appSlug)
|
||||
}
|
||||
|
||||
function requireHttpsRunUrl(runUrl) {
|
||||
@@ -640,6 +641,17 @@ async function main() {
|
||||
token: process.env.GITHUB_TOKEN ?? '',
|
||||
apiUrl: process.env.GITHUB_API_URL,
|
||||
})
|
||||
// The decision check run is published by the dedicated approval App so GitHub associates its check
|
||||
// suite with the pull request instead of whichever Actions suite started first. Both the token and
|
||||
// the App slug must be configured; otherwise the publisher keeps the job token, whose runs only
|
||||
// show on the Checks page.
|
||||
const appToken = process.env.DECISION_TOKEN
|
||||
const appSlug = process.env.DECISION_APP_SLUG
|
||||
const useApp = Boolean(appToken && appSlug)
|
||||
const checkApi = useApp
|
||||
? createGitHubApi({ token: appToken, apiUrl: process.env.GITHUB_API_URL })
|
||||
: api
|
||||
const checkAppSlug = useApp ? appSlug : 'github-actions'
|
||||
if (isRecord(event) && isRecord(event.issue)) {
|
||||
event = await approvalEventFromComment({ event, api })
|
||||
} else if (isRecord(event) && isRecord(event.workflow_run)) {
|
||||
@@ -653,7 +665,7 @@ async function main() {
|
||||
}
|
||||
const phase = process.argv[2]
|
||||
if (phase) {
|
||||
await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
|
||||
await publishApprovalPhase({ event, api, checkApi, checkAppSlug, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
|
||||
if (phase === 'pending' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
|
||||
return
|
||||
}
|
||||
@@ -661,6 +673,8 @@ async function main() {
|
||||
event,
|
||||
policySource,
|
||||
api,
|
||||
checkApi,
|
||||
checkAppSlug,
|
||||
runUrl: process.env.GITHUB_RUN_URL ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -497,6 +497,92 @@ test('publishes setup phases without evaluating or installing dependencies', asy
|
||||
await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
|
||||
})
|
||||
|
||||
test('updates the App-owned run instead of creating another', async () => {
|
||||
const calls = []
|
||||
const appRuns = [{ id: 42, app: { slug: 'dsh-weighted-approval' }, conclusion: 'success', external_id: 'weighted-approval-decision' }]
|
||||
await runPublisher({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
|
||||
checkAppSlug: 'dsh-weighted-approval',
|
||||
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
|
||||
getMergedCount: async () => 0,
|
||||
api: async (path, options = {}) => {
|
||||
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
|
||||
if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
|
||||
if (path.includes('/comments?')) return []
|
||||
if (path.includes('/statuses/')) return {}
|
||||
throw new Error(`unexpected status API path ${path}`)
|
||||
},
|
||||
checkApi: async (path, options = {}) => {
|
||||
calls.push({ path, method: options.method, body: options.body })
|
||||
if (isCheckRunsList(path)) return { total_count: appRuns.length, check_runs: appRuns }
|
||||
if (path.includes('/check-runs')) return {}
|
||||
throw new Error(`unexpected check API path ${path}`)
|
||||
},
|
||||
})
|
||||
assert.deepEqual(calls.map(call => call.path), [
|
||||
`/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`,
|
||||
'/repos/deepseek-harness/deepseek-harness/check-runs/42',
|
||||
])
|
||||
assert.equal(calls[1].method, 'PATCH')
|
||||
assert.equal(calls[1].body.name, undefined)
|
||||
})
|
||||
|
||||
test('ignores job-token runs when the App token publishes', async () => {
|
||||
const calls = []
|
||||
const jobRuns = [{ id: 7, app: { slug: 'github-actions' }, conclusion: 'success', external_id: 'weighted-approval-decision' }]
|
||||
await runPublisher({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
|
||||
checkAppSlug: 'dsh-weighted-approval',
|
||||
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
|
||||
getMergedCount: async () => 0,
|
||||
api: async (path) => {
|
||||
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
|
||||
if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
|
||||
if (path.includes('/comments?')) return []
|
||||
if (path.includes('/statuses/')) return {}
|
||||
throw new Error(`unexpected status API path ${path}`)
|
||||
},
|
||||
checkApi: async (path, options = {}) => {
|
||||
calls.push({ path, method: options.method, body: options.body })
|
||||
if (isCheckRunsList(path)) return { total_count: jobRuns.length, check_runs: jobRuns }
|
||||
if (path.includes('/check-runs')) return {}
|
||||
throw new Error(`unexpected check API path ${path}`)
|
||||
},
|
||||
})
|
||||
assert.equal(calls.at(-1).method, 'POST')
|
||||
assert.equal(calls.at(-1).body.name, 'weighted approval decision')
|
||||
assert.equal(calls.at(-1).body.external_id, 'weighted-approval-decision')
|
||||
})
|
||||
|
||||
test('publishes the check run through the App token when one is provided', async () => {
|
||||
const statusCalls = []
|
||||
const checkCalls = []
|
||||
await runPublisher({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
|
||||
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
|
||||
getMergedCount: async () => 0,
|
||||
api: async (path, options = {}) => {
|
||||
statusCalls.push({ path, body: options.body })
|
||||
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
|
||||
if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
|
||||
if (path.includes('/comments?')) return []
|
||||
if (path.includes('/statuses/')) return {}
|
||||
throw new Error(`unexpected status API path ${path}`)
|
||||
},
|
||||
checkApi: async (path, options = {}) => {
|
||||
checkCalls.push({ path, body: options.body })
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) return {}
|
||||
throw new Error(`unexpected check API path ${path}`)
|
||||
},
|
||||
})
|
||||
assert.equal(statusCalls.at(-1).body.context, 'weighted approval')
|
||||
assert.equal(checkCalls.at(-1).body.name, 'weighted approval decision')
|
||||
assert.equal(checkCalls.at(-1).body.conclusion, 'action_required')
|
||||
assert.ok(statusCalls.every(call => !call.path.includes('/check-runs')))
|
||||
assert.ok(checkCalls.every(call => !call.path.includes('/statuses/')))
|
||||
})
|
||||
|
||||
test('keeps the status gate when the label write fails', async () => {
|
||||
const writes = []
|
||||
const states = []
|
||||
@@ -561,6 +647,7 @@ test('revokes a published success check run and updates one run per head', async
|
||||
`/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`,
|
||||
`/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`,
|
||||
'/repos/deepseek-harness/deepseek-harness/check-runs/7',
|
||||
'/repos/deepseek-harness/deepseek-harness/check-runs/10',
|
||||
])
|
||||
for (const call of calls.slice(2)) {
|
||||
assert.equal(call.method, 'PATCH')
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
name: CI Python
|
||||
|
||||
# Python pull-request lanes run in their own workflow so a slow runtime build
|
||||
# does not keep the CI run in progress: GitHub re-runs failed jobs only after
|
||||
# every job of a run finishes, so each workflow can be re-run without waiting
|
||||
# for the others. Branch protection requires this workflow's "python checks
|
||||
# passed" verdict beside CI's "all checks passed" and CI Windows' "windows
|
||||
# checks passed".
|
||||
on:
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PRIMARY_NODE_VERSION: '24'
|
||||
# CI runs must never report to the production telemetry endpoint baked
|
||||
# into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
|
||||
# Cancel a superseded pull-request run on a new push, as in ci.yml.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
python-sdk:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
name: python 3.10 / keyless SDK
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
|
||||
- name: Install uv
|
||||
run: python -m pip install uv==0.11.23
|
||||
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Test production blame scoring
|
||||
run: uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'
|
||||
|
||||
- name: Run complete keyless Python suite
|
||||
run: uv run --python 3.10 --group test --project python/sdk pytest
|
||||
|
||||
# The reusable builder owns each published executable, wheel, clean-install,
|
||||
# keyless black-box, and trusted real-API path. Linux and Windows x64 block
|
||||
# pull requests; Linux ARM64 and both macOS architectures run in
|
||||
# ci-master.yml.
|
||||
python-runtime:
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: python-sdk
|
||||
name: python runtime / release-shaped matrix
|
||||
uses: ./.github/workflows/build-exe-for-python-sdk.yml
|
||||
with:
|
||||
targets: node24-linux-x64,node24-win-x64
|
||||
ci: true
|
||||
secrets:
|
||||
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
|
||||
# Stable required check for the Python lanes, with the same semantics as
|
||||
# ci.yml's "all checks passed": every blocking job in THIS workflow is
|
||||
# listed in `needs`. The bookkeeping job follows the Linux failover switch
|
||||
# like the other verdicts.
|
||||
python-checks-passed:
|
||||
name: python checks passed
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|
||||
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|
||||
|| 'ubuntu-latest' }}
|
||||
needs: [python-sdk, python-runtime]
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- name: Fail if any needed job did not succeed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')
|
||||
run: |
|
||||
echo "::error::Needed job results: ${{ join(needs.*.result, ', ') }}"
|
||||
exit 1
|
||||
- name: Python checks passed
|
||||
run: echo "All needed jobs succeeded (${{ join(needs.*.result, ', ') }})"
|
||||
@@ -0,0 +1,255 @@
|
||||
name: CI Windows
|
||||
|
||||
# Native Windows pull-request lanes run in their own workflow so a slow Windows
|
||||
# job does not keep the CI run in progress: GitHub re-runs failed jobs only
|
||||
# after every job of a run finishes, so each workflow can be re-run without
|
||||
# waiting for the others. Branch protection requires this workflow's
|
||||
# "windows checks passed" verdict beside CI's "all checks passed" and CI
|
||||
# Python's "python checks passed".
|
||||
on:
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PRIMARY_NODE_VERSION: '24'
|
||||
# CI runs must never report to the production telemetry endpoint baked
|
||||
# into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
|
||||
# Cancel a superseded pull-request run on a new push, as in ci.yml.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Native builds and process tests block pull requests independently of coverage.
|
||||
# Observational checks reuse the successful build and remain non-blocking.
|
||||
# Dependabot PRs are excluded from the self-hosted pool and stay queued for
|
||||
# the hosted runner.
|
||||
# Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage
|
||||
# and the shared build/observational workspace.
|
||||
windows-build:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
|
||||
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|
||||
|| 'dsh-windows-2025-16core' }}
|
||||
name: windows node 24 / build & observational
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# A failing build or site aborts the sibling gate on the same runner.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Developer Mode (symlink support)
|
||||
shell: pwsh
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
- name: Install (immutable)
|
||||
shell: pwsh
|
||||
# See 2026-08-30-windows-refs-store-block-clone-install for the
|
||||
# ReFS block-clone rationale; detect the workspace filesystem and
|
||||
# pass --package-import-method=clone only on ReFS.
|
||||
run: >-
|
||||
$drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':');
|
||||
$fs = (Get-Volume -DriveLetter $drive).FileSystem;
|
||||
if ($fs -eq 'ReFS') {
|
||||
corepack pnpm install --frozen-lockfile --package-import-method=clone
|
||||
} else {
|
||||
pnpm install --frozen-lockfile
|
||||
}
|
||||
- name: Run blocking Windows builds
|
||||
shell: pwsh
|
||||
run: pnpm run check:ci:windows-blocking
|
||||
- name: Parse Windows runner supervision scripts
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) {
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors)
|
||||
if ($parseErrors.Count) { throw ($parseErrors -join "`n") }
|
||||
}
|
||||
- name: Verify windowless Windows runner launcher
|
||||
shell: pwsh
|
||||
run: ./apps/desktop/tests/windows-runner-launcher.ps1
|
||||
- name: Run Windows observational gates on built artifacts
|
||||
id: observational
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
env:
|
||||
DSH_GATE_FAIL_FAST: ''
|
||||
DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
|
||||
run: pnpm run check:ci:windows-observational-ready
|
||||
- name: Report Windows observational failures
|
||||
continue-on-error: true
|
||||
if: steps.observational.outcome == 'failure'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$message = 'Windows observational checks failed; see the observational step for diagnostics.'
|
||||
Write-Output "::warning::$message"
|
||||
$message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
|
||||
windows-coverage:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
|
||||
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|
||||
|| 'dsh-windows-2025-16core' }}
|
||||
name: windows node 24 / coverage
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
# Shared hosts expose host CPUs, not an isolated allocation.
|
||||
DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }}
|
||||
DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }}
|
||||
DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }}
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
|
||||
# A gate failure aborts the sibling gate instead of waiting out its
|
||||
# multi-minute instrumented run.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Checkout removes the gitignored duration history, so restore it from
|
||||
# the GitHub cache before coverage and save the updated one afterwards.
|
||||
# The coordinator then weights partitions by measured durations on the
|
||||
# second run instead of degrading to a file-count split. GitHub cache
|
||||
# entries are immutable, so the save key is unique per run and the
|
||||
# restore matches the newest entry through the stable prefix.
|
||||
- name: Restore coverage duration history
|
||||
uses: actions/cache/[email protected]
|
||||
with:
|
||||
path: .coverage-times.json
|
||||
key: coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-
|
||||
- name: Enable Developer Mode (symlink support)
|
||||
shell: pwsh
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
- name: Install (immutable)
|
||||
shell: pwsh
|
||||
# See 2026-08-30-windows-refs-store-block-clone-install for the
|
||||
# ReFS block-clone rationale; detect the workspace filesystem and
|
||||
# pass --package-import-method=clone only on ReFS.
|
||||
run: >-
|
||||
$drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':');
|
||||
$fs = (Get-Volume -DriveLetter $drive).FileSystem;
|
||||
if ($fs -eq 'ReFS') {
|
||||
corepack pnpm install --frozen-lockfile --package-import-method=clone
|
||||
} else {
|
||||
pnpm install --frozen-lockfile
|
||||
}
|
||||
# No build before coverage, matching the Linux lane: workspace imports
|
||||
# resolve to src through the tsconfig paths map, and the lib-consuming
|
||||
# suites (webworker-packer image-loadable, webworker-runtime
|
||||
# transform-corpus, client ui-trajectory client-bundle) self-skip on
|
||||
# unbuilt checkouts.
|
||||
- name: Run Windows coverage
|
||||
shell: pwsh
|
||||
run: pnpm run check:ci:coverage
|
||||
- name: Save coverage duration history
|
||||
# Coverage flakes must not prevent the cache from building; the
|
||||
# measured durations remain useful even when a partition failed. The
|
||||
# per-run key keeps every save a fresh immutable cache entry.
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/cache/[email protected]
|
||||
with:
|
||||
path: .coverage-times.json
|
||||
key: coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
windows-native-tests:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025'
|
||||
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|
||||
|| 'dsh-windows-2025-16core' }}
|
||||
name: windows node 24 / native tests
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Developer Mode (symlink support)
|
||||
shell: pwsh
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
- name: Install (immutable)
|
||||
shell: pwsh
|
||||
# See 2026-08-30-windows-refs-store-block-clone-install for the
|
||||
# ReFS block-clone rationale; detect the workspace filesystem and
|
||||
# pass --package-import-method=clone only on ReFS.
|
||||
run: >-
|
||||
$drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':');
|
||||
$fs = (Get-Volume -DriveLetter $drive).FileSystem;
|
||||
if ($fs -eq 'ReFS') {
|
||||
corepack pnpm install --frozen-lockfile --package-import-method=clone
|
||||
} else {
|
||||
pnpm install --frozen-lockfile
|
||||
}
|
||||
- name: Run Windows-specific native tests
|
||||
shell: pwsh
|
||||
run: >-
|
||||
pnpm exec vitest run
|
||||
--no-file-parallelism
|
||||
--testTimeout 90000
|
||||
packages/shell/tool-pwsh/tests/loader.spec.ts
|
||||
packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts
|
||||
packages/experimental/tool-ralph/tests/integration.spec.ts
|
||||
packages/subprocess/subprocess-local/tests/process-exit.spec.ts
|
||||
|
||||
# Stable required check for the Windows lanes, with the same semantics as
|
||||
# ci.yml's "all checks passed": every blocking job in THIS workflow is
|
||||
# listed in `needs`. windows-coverage stays outside the verdict, so its
|
||||
# per-file result remains visible without blocking. The bookkeeping job
|
||||
# follows the Linux failover switch like the CI verdict.
|
||||
windows-checks-passed:
|
||||
name: windows checks passed
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404'
|
||||
|| vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|
||||
|| 'ubuntu-latest' }}
|
||||
needs: [windows-build, windows-native-tests]
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- name: Fail if any needed job did not succeed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')
|
||||
run: |
|
||||
echo "::error::Needed job results: ${{ join(needs.*.result, ', ') }}"
|
||||
exit 1
|
||||
- name: Windows checks passed
|
||||
run: echo "All needed jobs succeeded (${{ join(needs.*.result, ', ') }})"
|
||||
+8
-254
@@ -12,7 +12,7 @@ env:
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
|
||||
# Cancel a superseded pull-request run on a new push so a fresh head does not
|
||||
# queue a second full 9-job run behind a stale one (paid enterprise runners
|
||||
# queue a second full run behind a stale one (paid enterprise runners
|
||||
# would otherwise stack with no auto-cancellation).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -36,9 +36,8 @@ jobs:
|
||||
# Blacksmith's hosted runners sized per workload (see
|
||||
# .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The
|
||||
# in-house pool's readiness is re-proven on every master push by the
|
||||
# serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover
|
||||
# switch is the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native
|
||||
# job below.
|
||||
# serial-linux-selfhosted standby lane in ci-master.yml. Native Windows jobs
|
||||
# and their separate DSH_CI_FAILOVER_WINDOWS switch live in ci-windows.yml.
|
||||
node-24:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
@@ -478,258 +477,13 @@ jobs:
|
||||
- name: Check Loader internal shape detection
|
||||
run: pnpm exec vitest run packages/boot/app-boot/tests/loader-shape.compat.spec.ts
|
||||
|
||||
python-sdk:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
name: python 3.10 / keyless SDK
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
|
||||
- name: Install uv
|
||||
run: python -m pip install uv==0.11.23
|
||||
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Test production blame scoring
|
||||
run: uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'
|
||||
|
||||
- name: Run complete keyless Python suite
|
||||
run: uv run --python 3.10 --group test --project python/sdk pytest
|
||||
|
||||
# The reusable builder owns each published executable, wheel, clean-install,
|
||||
# keyless black-box, and trusted real-API path. Linux/Windows x64 block PRs;
|
||||
# Linux ARM64 and both macOS architectures run in ci-master.yml.
|
||||
python-runtime:
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: python-sdk
|
||||
name: python runtime / release-shaped matrix
|
||||
uses: ./.github/workflows/build-exe-for-python-sdk.yml
|
||||
with:
|
||||
targets: node24-linux-x64,node24-win-x64
|
||||
ci: true
|
||||
secrets:
|
||||
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
|
||||
# Native builds and process tests block pull requests independently of coverage.
|
||||
# Observational checks reuse the successful build and remain non-blocking.
|
||||
# Dependabot PRs are excluded from the self-hosted pool and stay queued for
|
||||
# the hosted runner.
|
||||
# Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage
|
||||
# and the shared build/observational workspace.
|
||||
windows-build:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
|
||||
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|
||||
|| 'dsh-windows-2025-16core' }}
|
||||
name: windows node 24 / build & observational
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# A failing build or site aborts the sibling gate on the same runner.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Developer Mode (symlink support)
|
||||
shell: pwsh
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
- name: Install (immutable)
|
||||
shell: pwsh
|
||||
# See 2026-08-30-windows-refs-store-block-clone-install for the
|
||||
# ReFS block-clone rationale; detect the workspace filesystem and
|
||||
# pass --package-import-method=clone only on ReFS.
|
||||
run: >-
|
||||
$drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':');
|
||||
$fs = (Get-Volume -DriveLetter $drive).FileSystem;
|
||||
if ($fs -eq 'ReFS') {
|
||||
corepack pnpm install --frozen-lockfile --package-import-method=clone
|
||||
} else {
|
||||
pnpm install --frozen-lockfile
|
||||
}
|
||||
- name: Run blocking Windows builds
|
||||
shell: pwsh
|
||||
run: pnpm run check:ci:windows-blocking
|
||||
- name: Parse Windows runner supervision scripts
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) {
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors)
|
||||
if ($parseErrors.Count) { throw ($parseErrors -join "`n") }
|
||||
}
|
||||
- name: Verify windowless Windows runner launcher
|
||||
shell: pwsh
|
||||
run: ./apps/desktop/tests/windows-runner-launcher.ps1
|
||||
- name: Run Windows observational gates on built artifacts
|
||||
id: observational
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
env:
|
||||
DSH_GATE_FAIL_FAST: ''
|
||||
DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }}
|
||||
run: pnpm run check:ci:windows-observational-ready
|
||||
- name: Report Windows observational failures
|
||||
continue-on-error: true
|
||||
if: steps.observational.outcome == 'failure'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$message = 'Windows observational checks failed; see the observational step for diagnostics.'
|
||||
Write-Output "::warning::$message"
|
||||
$message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
|
||||
windows-coverage:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
|
||||
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|
||||
|| 'dsh-windows-2025-16core' }}
|
||||
name: windows node 24 / coverage
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
# Shared hosts expose host CPUs, not an isolated allocation.
|
||||
DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }}
|
||||
DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }}
|
||||
DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }}
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
|
||||
# A gate failure aborts the sibling gate instead of waiting out its
|
||||
# multi-minute instrumented run.
|
||||
DSH_GATE_FAIL_FAST: '1'
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Checkout removes the gitignored duration history, so restore it from
|
||||
# the GitHub cache before coverage and save the updated one afterwards.
|
||||
# The coordinator then weights partitions by measured durations on the
|
||||
# second run instead of degrading to a file-count split. GitHub cache
|
||||
# entries are immutable, so the save key is unique per run and the
|
||||
# restore matches the newest entry through the stable prefix.
|
||||
- name: Restore coverage duration history
|
||||
uses: actions/cache/[email protected]
|
||||
with:
|
||||
path: .coverage-times.json
|
||||
key: coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-
|
||||
- name: Enable Developer Mode (symlink support)
|
||||
shell: pwsh
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
- name: Install (immutable)
|
||||
shell: pwsh
|
||||
# See 2026-08-30-windows-refs-store-block-clone-install for the
|
||||
# ReFS block-clone rationale; detect the workspace filesystem and
|
||||
# pass --package-import-method=clone only on ReFS.
|
||||
run: >-
|
||||
$drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':');
|
||||
$fs = (Get-Volume -DriveLetter $drive).FileSystem;
|
||||
if ($fs -eq 'ReFS') {
|
||||
corepack pnpm install --frozen-lockfile --package-import-method=clone
|
||||
} else {
|
||||
pnpm install --frozen-lockfile
|
||||
}
|
||||
# No build before coverage, matching the Linux lane: workspace imports
|
||||
# resolve to src through the tsconfig paths map, and the lib-consuming
|
||||
# suites (webworker-packer image-loadable, webworker-runtime
|
||||
# transform-corpus, client ui-trajectory client-bundle) self-skip on
|
||||
# unbuilt checkouts.
|
||||
- name: Run Windows coverage
|
||||
shell: pwsh
|
||||
run: pnpm run check:ci:coverage
|
||||
- name: Save coverage duration history
|
||||
# Coverage flakes must not prevent the cache from building; the
|
||||
# measured durations remain useful even when a partition failed. The
|
||||
# per-run key keeps every save a fresh immutable cache entry.
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/cache/[email protected]
|
||||
with:
|
||||
path: .coverage-times.json
|
||||
key: coverage-times-Windows-${{ runner.environment }}-${{ vars.DSH_CI_FAILOVER_WINDOWS || 'enterprise' }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
windows-native-tests:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: >-
|
||||
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025'
|
||||
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
|
||||
&& github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|
||||
|| 'dsh-windows-2025-16core' }}
|
||||
name: windows node 24 / native tests
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Developer Mode (symlink support)
|
||||
shell: pwsh
|
||||
run: >-
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
|
||||
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
- name: Install (immutable)
|
||||
shell: pwsh
|
||||
# See 2026-08-30-windows-refs-store-block-clone-install for the
|
||||
# ReFS block-clone rationale; detect the workspace filesystem and
|
||||
# pass --package-import-method=clone only on ReFS.
|
||||
run: >-
|
||||
$drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':');
|
||||
$fs = (Get-Volume -DriveLetter $drive).FileSystem;
|
||||
if ($fs -eq 'ReFS') {
|
||||
corepack pnpm install --frozen-lockfile --package-import-method=clone
|
||||
} else {
|
||||
pnpm install --frozen-lockfile
|
||||
}
|
||||
- name: Run Windows-specific native tests
|
||||
shell: pwsh
|
||||
run: >-
|
||||
pnpm exec vitest run
|
||||
--no-file-parallelism
|
||||
--testTimeout 90000
|
||||
packages/shell/tool-pwsh/tests/loader.spec.ts
|
||||
packages/workflow/workflow-ptc/tests/workflow-ptc.spec.ts
|
||||
packages/experimental/tool-ralph/tests/integration.spec.ts
|
||||
packages/subprocess/subprocess-local/tests/process-exit.spec.ts
|
||||
|
||||
# Single stable required check for branch protection: require "all checks
|
||||
# passed" instead of enumerating matrix legs whose names change as lanes and
|
||||
# node versions evolve. Every blocking job in THIS workflow must be listed in
|
||||
# `needs`. Native Windows build and process checks are required; Wine and
|
||||
# the deferred Python runtime targets live in ci-master.yml and do not
|
||||
# participate in this PR verdict. `needs` cannot cross workflow files.
|
||||
# `needs`. `needs` cannot cross workflow files: native Windows jobs report
|
||||
# the separate "windows checks passed" verdict from ci-windows.yml, Python
|
||||
# jobs report "python checks passed" from ci-python.yml, and Wine plus the
|
||||
# master-only runtime targets live in ci-master.yml.
|
||||
# An explicit status function runs the verdict after failed/skipped needs
|
||||
# without keeping a cancelled workflow alive for an obsolete verdict.
|
||||
all-checks-passed:
|
||||
@@ -749,7 +503,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, python-sdk, python-runtime, windows-build, windows-native-tests]
|
||||
needs: [node-24, node-24-coverage, 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
|
||||
|
||||
@@ -37,10 +37,26 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
# The decision check run is published by a dedicated approval App so GitHub associates its
|
||||
# check suite with the pull request and no other workflow owns that identity; the job token
|
||||
# alone keeps the run out of the merge box. The App holds only Checks write. A missing App
|
||||
# permission leaves the token empty and the publisher falls back to the job token.
|
||||
- name: Create decision check token
|
||||
id: decision-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.DSH_APPROVAL_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.DSH_APPROVAL_APP_PRIVATE_KEY }}
|
||||
owner: deepseek-harness
|
||||
repositories: deepseek-harness
|
||||
permission-checks: write
|
||||
- name: Revoke previous approval status
|
||||
id: revoke
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
DECISION_TOKEN: ${{ steps.decision-token.outputs.token }}
|
||||
DECISION_APP_SLUG: ${{ vars.DSH_APPROVAL_APP_SLUG }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs pending
|
||||
# SECURITY: dependency setup runs in the status-writing job.
|
||||
@@ -57,11 +73,15 @@ jobs:
|
||||
if: steps.revoke.outputs.active == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
DECISION_TOKEN: ${{ steps.decision-token.outputs.token }}
|
||||
DECISION_APP_SLUG: ${{ vars.DSH_APPROVAL_APP_SLUG }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs
|
||||
- name: Publish approval setup failure
|
||||
if: failure() && steps.revoke.outputs.active == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
DECISION_TOKEN: ${{ steps.decision-token.outputs.token }}
|
||||
DECISION_APP_SLUG: ${{ vars.DSH_APPROVAL_APP_SLUG }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs error
|
||||
@@ -114,6 +114,7 @@ External packages installed for runtime use or embedded in distributed Host or b
|
||||
| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | 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 |
|
||||
| [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
|
||||
| [`minisearch`](https://github.com/lucaong/minisearch) | MIT |
|
||||
| [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
|
||||
@@ -159,6 +160,7 @@ pnpm applies local patches to the following packages at install time, so shipped
|
||||
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
|
||||
## Chrome DevTools frontend
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-jobs-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-launch-environment": "workspace:*",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:*",
|
||||
"@deepseek-ai/dsh-official-bundles": "workspace:*",
|
||||
"@deepseek-ai/dsh-persona": "workspace:*",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:*",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:*",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Profile package management and explicit, exact-version compatibility approvals. */
|
||||
import { runPluginCommand, runProfilePnpm, setProfileVersionExemption, type PackageOperationOptions } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
import { INSTALL_ANCHOR } from './profile-boot.ts'
|
||||
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
|
||||
import { initProfile, readProfileCompatibility, resolveProfileDir, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
|
||||
import { PROFILE_TEMPLATES, DEFAULT_PROFILE_BUNDLES } from '@deepseek-ai/dsh-official-bundles'
|
||||
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
|
||||
@@ -26,12 +26,12 @@ import {
|
||||
reportSkippedBundles,
|
||||
PluginPackages,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
PROFILE_TEMPLATES,
|
||||
resolveProfileDir,
|
||||
type ProfileContext,
|
||||
type Profile,
|
||||
type RuntimeResolution,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
|
||||
import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
|
||||
@@ -13,7 +13,8 @@ import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import { initProfile, PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot'
|
||||
import { initProfile } from '@deepseek-ai/dsh-app-boot'
|
||||
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
const BUILT_BIN = join(REPO_ROOT, 'apps/cli/lib/bin.js')
|
||||
|
||||
@@ -3,7 +3,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { getDshRuntimeVersion, initProfile, PROFILE_TEMPLATES, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
|
||||
import { getDshRuntimeVersion, initProfile, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
|
||||
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles'
|
||||
import { runPluginCommand, runProfilePnpm } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
import { runPlugin } from '../src/plugin.ts'
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
initProfile,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
PROFILE_TEMPLATES,
|
||||
readProfileManifest,
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { execa } from 'execa'
|
||||
import { initializeProfileFromDefault } from '../src/profile-boot.ts'
|
||||
|
||||
@@ -5,7 +5,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { composeEntries, loadProfile, PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot'
|
||||
import { composeEntries, loadProfile } from '@deepseek-ai/dsh-app-boot'
|
||||
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles'
|
||||
|
||||
const installAnchor = fileURLToPath(new URL('../package.json', import.meta.url))
|
||||
const resourcePackage = '@deepseek-ai/dsh-mcp-resources'
|
||||
|
||||
@@ -41,3 +41,9 @@
|
||||
config:
|
||||
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
# A search key reference no runner sets: the turn runs as a deployment without
|
||||
# web search credentials, whatever the launching environment exports.
|
||||
- id: web-search-deepseek
|
||||
config:
|
||||
apiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY
|
||||
@@ -60,7 +60,9 @@ describe('headless-agent keyless smoke', () => {
|
||||
})
|
||||
expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP')
|
||||
expect(persistedHeader).toMatchObject({ type: 'session' })
|
||||
expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch', 'web_search']))
|
||||
expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch']))
|
||||
// Search has no key in this composition, so the model request does not offer it.
|
||||
expect(persistedToolNames).not.toContain('web_search')
|
||||
expect(persistedToolNames).not.toContain('str_replace_editor')
|
||||
}, PRODUCTION_PROFILE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
fixtureContext,
|
||||
normalizeSessionSnapshot,
|
||||
normalizeSessionSnapshots,
|
||||
REPLAY_CREDENTIAL_ENV,
|
||||
type NormalizeContext,
|
||||
} from '@deepseek-ai/dsh-session-snapshot'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
@@ -104,6 +105,7 @@ describe('parent-only override inheritance snapshot', () => {
|
||||
binArgs: [configPath, task],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
...REPLAY_CREDENTIAL_ENV,
|
||||
// The primary fixture path must exist for llm-replay's config guard;
|
||||
// the override sidecar fully replaces the derived parent script.
|
||||
DSH_SNAPSHOT_FILE: replayOverride,
|
||||
|
||||
@@ -3,9 +3,9 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { zstdDecompressSync } from 'node:zlib'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { scanZstdFrames } from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.js'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
@@ -17,7 +17,6 @@ const launch = resolveExampleLaunch({
|
||||
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)),
|
||||
mode: 'lib',
|
||||
})
|
||||
const decompress = promisify(zstdDecompress)
|
||||
|
||||
|
||||
function waitForLine(
|
||||
@@ -199,11 +198,30 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
|
||||
const sessionsRoot = join(root, '.dsh', 'sessions')
|
||||
const files = await readdir(sessionsRoot, { recursive: true })
|
||||
const log = files.find(file => file.endsWith('.jsonl.zstd'))
|
||||
// Session logs are JSONL across one zstd frame per batch, header frame
|
||||
// first. Decompress frames explicitly: a single call over concatenated
|
||||
// frames returned only the first frame on Node 24.19 and returns every
|
||||
// frame on 24.22, so neither behavior may back the assertion.
|
||||
const logs: { compressed: Buffer; header: Record<string, unknown> }[] = []
|
||||
for (const file of files.filter(file => file.endsWith('.jsonl.zstd'))) {
|
||||
const compressed = await readFile(join(sessionsRoot, file))
|
||||
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
|
||||
const first = scanZstdFrames(compressed).frames[0]
|
||||
if (first === undefined) continue
|
||||
const headerLine = zstdDecompressSync(compressed.subarray(first.start, first.end)).toString().trim().split('\n')[0]
|
||||
if (headerLine === undefined) continue
|
||||
logs.push({ compressed, header: JSON.parse(headerLine) as Record<string, unknown> })
|
||||
}
|
||||
const log = logs.find(entry => entry.header.type === 'session' && entry.header.id === 'main')
|
||||
expect(log).toBeDefined()
|
||||
const compressed = await readFile(join(sessionsRoot, log!))
|
||||
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
|
||||
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
|
||||
const { frames, tornStart } = scanZstdFrames(log!.compressed)
|
||||
expect(tornStart).toBeUndefined()
|
||||
const records = frames
|
||||
.flatMap(({ start, end }) => zstdDecompressSync(log!.compressed.subarray(start, end)).toString().trim().split('\n'))
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
expect(records[0]).toMatchObject({ type: 'session', id: 'main' })
|
||||
expect(records.length).toBeGreaterThan(1)
|
||||
expect(records.some(record => record.type === 'turn/end')).toBe(true)
|
||||
} finally {
|
||||
// No-op after exit; reject: false settles on every outcome, so cleanup never races teardown.
|
||||
child.kill('SIGKILL')
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../packages/boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/boot/official-bundles"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/boot/cmdline"
|
||||
},
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:~",
|
||||
"@deepseek-ai/dsh-skill": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:*",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-executor-native": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Resolve desktop-only file references through the active Host filesystem and Session. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
|
||||
/** Private Host-to-main answer; never exposed to renderer JavaScript. */
|
||||
export interface LocalDragResource {
|
||||
readonly path: string
|
||||
readonly sha256: string
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize an unmodified local file through the composed filesystem's actual read operations.
|
||||
* @param ctx - booted Desktop Host.
|
||||
* @param address - Session-scoped resource reference.
|
||||
* @param cancellation - caller cancellation, combined with the read deadline.
|
||||
* @returns verified Host-local path and content identity, or null for nonlocal resources.
|
||||
*/
|
||||
export async function localDragResource(ctx: Context, address: string, cancellation?: AbortSignal): Promise<LocalDragResource | null> {
|
||||
const resource = parseFileAddress(address)
|
||||
if (resource?.scope !== 'session') throw new Error('File drag requires a Session resource')
|
||||
const sessions = ctx.get('sessions')
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
const fs = ctx.get('fs')
|
||||
const policy = ctx.get('sandboxPolicy')
|
||||
if (fs === undefined || sessions === undefined || policy === undefined) throw new Error('File access unavailable')
|
||||
const sessionId = resource.sessionId as SessionId
|
||||
const header = sessions.get(sessionId)?.header ?? (await persistence?.stat(sessionId))?.header
|
||||
if (header === undefined) throw new Error('File drag Session unavailable')
|
||||
const signal = cancellation === undefined ? AbortSignal.timeout(10_000) : AbortSignal.any([cancellation, AbortSignal.timeout(10_000)])
|
||||
signal.throwIfAborted()
|
||||
const cwd = header.cwd ?? policy.workspaceRoot
|
||||
const entry = await fs.lstat(resource.path, { cwd }, signal)
|
||||
if (entry?.type !== 'file') return null
|
||||
const target = await fs.resolve(resource.path, { cwd, signal })
|
||||
const path = fs.processPath(target)
|
||||
const mapped = fs.processPathFromHostPath(path)
|
||||
if (mapped === undefined) return null
|
||||
const roundTrip = await fs.resolve(mapped, { signal })
|
||||
if (roundTrip.targetKey !== target.targetKey) return null
|
||||
const info = await fs.stat(target, signal)
|
||||
if (info?.type !== 'file' || info.size === undefined) return null
|
||||
const hash = createHash('sha256')
|
||||
let size = 0
|
||||
while (size < info.size) {
|
||||
signal.throwIfAborted()
|
||||
const data = await fs.readByteRange(target, { offset: size, length: Math.min(64 * 1024, info.size - size) }, signal)
|
||||
if (data.length === 0) return null
|
||||
hash.update(data)
|
||||
size += data.length
|
||||
}
|
||||
// Even empty files require a read authorization check.
|
||||
if (size === 0) await fs.readByteRange(target, { offset: 0, length: 1 }, signal)
|
||||
const after = await fs.stat(target, signal)
|
||||
signal.throwIfAborted()
|
||||
if (after?.version !== info.version || after.size !== size) return null
|
||||
return { path, size, sha256: hash.digest('hex') }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-deepseek-account'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import * as desktopOffice from './office.ts'
|
||||
import { localDragResource } from './file-drag.ts'
|
||||
|
||||
import { installDesktopUpdateTaskControl } from './update-tasks.ts'
|
||||
import { installDesktopQuitInspection } from './quit-inspection.ts'
|
||||
@@ -41,6 +42,8 @@ async function main(): Promise<void> {
|
||||
}),
|
||||
})
|
||||
let stopping: Promise<void> | undefined
|
||||
const dragReads = new Map<number, AbortController>()
|
||||
const dragTasks = new Set<Promise<void>>()
|
||||
const control: {
|
||||
updateTasks?: ReturnType<typeof installDesktopUpdateTaskControl>
|
||||
quitInspection?: ReturnType<typeof installDesktopQuitInspection>
|
||||
@@ -50,6 +53,8 @@ async function main(): Promise<void> {
|
||||
process.send(message, (error) => { if (error === null) resolve(); else reject(error) })
|
||||
})
|
||||
const stop = (): Promise<void> => stopping ??= (async () => {
|
||||
for (const abort of dragReads.values()) abort.abort()
|
||||
await Promise.allSettled(dragTasks)
|
||||
// Startup failure is reported by main; shutdown only owns a tree that booted.
|
||||
const running = await application.catch(() => undefined)
|
||||
await running?.shutdown.shutdown(0)
|
||||
@@ -62,6 +67,31 @@ async function main(): Promise<void> {
|
||||
process.on('message', (message: unknown) => {
|
||||
if (typeof message !== 'object' || message === null || !('type' in message)) return
|
||||
if (message.type === 'shutdown') { void stop(); return }
|
||||
if (message.type === 'file-drag-cancel') {
|
||||
if ('requestId' in message && typeof message.requestId === 'number') dragReads.get(message.requestId)?.abort()
|
||||
return
|
||||
}
|
||||
if (message.type === 'file-drag-resource') {
|
||||
if (!('requestId' in message) || typeof message.requestId !== 'number' || !Number.isSafeInteger(message.requestId)
|
||||
|| !('address' in message) || typeof message.address !== 'string') return
|
||||
const { requestId, address } = message
|
||||
const abort = new AbortController()
|
||||
dragReads.set(requestId, abort)
|
||||
const task = (async () => {
|
||||
try {
|
||||
if (stopping !== undefined) throw new Error('Host stopping')
|
||||
const { ctx } = await application
|
||||
const resource = await localDragResource(ctx, address, abort.signal)
|
||||
await send({ type: 'file-drag-resource', requestId, resource })
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) console.error(error)
|
||||
await send({ type: 'file-drag-resource', requestId, resource: null, error: 'File drag resource unavailable' })
|
||||
} finally { dragReads.delete(requestId) }
|
||||
})().catch((error: unknown) => { console.error(error) })
|
||||
dragTasks.add(task)
|
||||
void task.finally(() => { dragTasks.delete(task) })
|
||||
return
|
||||
}
|
||||
if (message.type === 'quit-inspection') {
|
||||
if (!('requestId' in message) || !Number.isSafeInteger(message.requestId)) return
|
||||
const requestId = message.requestId
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { localDragResource } from '../src/file-drag.ts'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-host-drag-'))
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
|
||||
await ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.provide('sandboxPolicy', { workspaceRoot: root } as never)
|
||||
const id = SessionId('file-drag-test')
|
||||
ctx.sessions.create(id, { meta: { cwd: root, origin: 'subagent' } })
|
||||
const path = join(root, '报告.pdf')
|
||||
const bytes = Buffer.from('%PDF-1.4\n原始内容\n%%EOF')
|
||||
await writeFile(path, bytes)
|
||||
return { ctx, root, id, path, bytes, address: sessionFileAddress(id, '报告.pdf') }
|
||||
}
|
||||
|
||||
it('authorizes Session-relative file reads and returns their original digest privately to main', async () => {
|
||||
const f = await fixture()
|
||||
expect(await localDragResource(f.ctx, f.address)).toEqual({
|
||||
path: await realpath(f.path), size: f.bytes.length, sha256: createHash('sha256').update(f.bytes).digest('hex'),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unscoped references and missing Sessions', async () => {
|
||||
const f = await fixture()
|
||||
await expect(localDragResource(f.ctx, f.path)).rejects.toThrow('Session resource')
|
||||
await expect(localDragResource(f.ctx, sessionFileAddress('missing', f.path))).rejects.toThrow('Session unavailable')
|
||||
await expect(localDragResource(f.ctx, sessionFileAddress(f.id, f.root))).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('does not authorize borrowing when the filesystem has no Host mapping', async () => {
|
||||
const f = await fixture()
|
||||
vi.spyOn(f.ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
||||
expect(await localDragResource(f.ctx, f.address)).toBeNull()
|
||||
})
|
||||
|
||||
it('honors filesystem read denial without returning a path', async () => {
|
||||
const f = await fixture()
|
||||
vi.spyOn(f.ctx.fs, 'readByteRange').mockRejectedValue(new Error('read denied'))
|
||||
await expect(localDragResource(f.ctx, f.address)).rejects.toThrow('read denied')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('declines symlink borrowing so the authorized byte reader can copy it', async () => {
|
||||
const f = await fixture()
|
||||
await symlink(f.path, join(f.root, 'linked.pdf'))
|
||||
expect(await localDragResource(f.ctx, sessionFileAddress(f.id, 'linked.pdf'))).toBeNull()
|
||||
})
|
||||
|
||||
it('stops a chunked authorization read when the caller cancels', async () => {
|
||||
const f = await fixture()
|
||||
const pending = Promise.withResolvers<Uint8Array>()
|
||||
const reading = vi.spyOn(f.ctx.fs, 'readByteRange').mockReturnValue(pending.promise)
|
||||
const abort = new AbortController()
|
||||
const preparing = localDragResource(f.ctx, f.address, abort.signal)
|
||||
const rejected = expect(preparing).rejects.toThrow()
|
||||
await vi.waitFor(() => { expect(reading).toHaveBeenCalledOnce() })
|
||||
const signal = reading.mock.calls[0]![2]
|
||||
abort.abort()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
pending.resolve(f.bytes)
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('authorizes empty files and detects content changes while reading', async () => {
|
||||
const f = await fixture()
|
||||
await writeFile(f.path, '')
|
||||
expect(await localDragResource(f.ctx, f.address)).toMatchObject({ size: 0, sha256: createHash('sha256').digest('hex') })
|
||||
await writeFile(f.path, f.bytes)
|
||||
vi.spyOn(f.ctx.fs, 'readByteRange').mockResolvedValueOnce(new Uint8Array())
|
||||
await expect(localDragResource(f.ctx, f.address)).resolves.toBeNull()
|
||||
})
|
||||
@@ -30,6 +30,24 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/credentials/deepseek-account"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/workspace-path"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/brand"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 372297005b57a08b
|
||||
zh: 074a635ac17e4362
|
||||
/deepseek-harness-desktop/installation-ownership:
|
||||
en: ae0384d699f55cea
|
||||
zh: ffb879b1d4b08bae
|
||||
en: 7cbb1f9e5006bd34
|
||||
zh: 01e9e15a75a90450
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: a1a47b09f4a72ba4
|
||||
zh: 7b1cf27f0b72cf31
|
||||
|
||||
@@ -142,6 +142,8 @@ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages
|
||||
|
||||
The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
|
||||
|
||||
The file-drag bridge accepts arbitrary file bytes or Session-scoped resource references from the owned product main frame. The Host authorizes resource reads through its composed filesystem, confirms a local mapping, and returns a private content digest; main checks content and the shared export filename before borrowing the source. Native startup checks the prepared file’s device, inode, size, modification time, and change time without rereading its bytes. Borrowed files are never deleted. Renamed, transformed, remote, or Blob content uses a private temporary copy. MIME data completes names without restricting file types; compatible suffixes such as `.jpeg` survive. Temporary preparations, partial writes, and retained exports share 64 MiB; unused files release on pointer leave, and OS-delivered copies expire after 60 seconds. Exhaustion reports the generic retry message until capacity is reclaimed. Caller cancellation and shutdown abort Host reads and main-process verification, interrupt icon waits, and release unused files. Shutdown waits for writes/releases and retries failed removals without waiting for the icon service. Update preparation attempts cleanup before stopping the Host; cleanup failures are logged and do not block installation or shutdown. Failed installation restores the bridge. Asynchronous system file icons avoid decoding original images in main.
|
||||
|
||||
Only the main application window enables `<webview>`. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering.
|
||||
|
||||
The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks.
|
||||
|
||||
@@ -142,6 +142,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
|
||||
|
||||
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
|
||||
|
||||
文件拖放桥接接受所属主应用主框架传入的任意文件字节或 Session 资源地址。Host 通过组合的文件系统授权读取、确认本地映射并返回私有内容摘要;主进程检查内容与统一解析的导出名后借用源文件。原生起拖核对准备时的设备、inode、大小、修改时间和变更时间,不再读取整份字节。借用文件不会被删除。改名、转换、远程或 Blob 内容使用私有临时副本。MIME 数据补全名称,不限制文件类型;`.jpeg` 等兼容后缀保持不变。临时准备、部分写入及保留的导出共享 64 MiB;指针移开释放未使用文件,已交给系统的副本在 60 秒后过期。空间耗尽时显示通用重试提示,容量回收后可重试。调用方取消及退出会中止 Host 读取和主进程验证、打断图标等待并释放未使用文件。退出等待写入和释放,重试失败的删除,但不等待图标服务。更新准备在停止 Host 前尝试清理;清理失败只记录日志,不阻断安装或退出。安装失败后恢复桥接。异步系统文件图标避免主进程解码原图。
|
||||
|
||||
只有主应用窗口启用 `<webview>`。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。
|
||||
|
||||
`dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*",
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:*",
|
||||
"@deepseek-ai/dsh-official-bundles": "workspace:*",
|
||||
"@deepseek-ai/node-addon-system": "workspace:~",
|
||||
"@electron/get": "^5.1.0",
|
||||
"@electron/notarize": "2.5.0",
|
||||
@@ -82,6 +83,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-file-drag": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly<Record<string, DesktopRuntimePatc
|
||||
'@yao-pkg/[email protected]': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' },
|
||||
'[email protected]': { scope: 'workspace-only', reason: 'DMG format validation executes on the build host.' },
|
||||
'[email protected]': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' },
|
||||
'[email protected]': { scope: 'workspace-only', reason: 'The patched editor is embedded in the client bundle.' },
|
||||
'[email protected]': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' },
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/** Application-owned temporary exports and authorized borrowed files for native dragging. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { resolveDragFileName, type DragFileName, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
|
||||
|
||||
// Aggregate temporary bytes, including incomplete writes and failed removals.
|
||||
const MAX_TOTAL_DRAG_FILE_BYTES = 64 * 1024 * 1024
|
||||
// Native receivers may open the path after startDrag returns.
|
||||
const DELIVERED_FILE_RETENTION_MS = 60_000
|
||||
|
||||
interface DragFile {
|
||||
readonly directory?: string
|
||||
readonly path: string
|
||||
readonly bytes: number
|
||||
ready: boolean
|
||||
used: boolean
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/** Borrowed files are never deleted; temporary exports retain cleanup ownership until removal succeeds. */
|
||||
export class DragFiles {
|
||||
private readonly files = new Map<FileDragHandle, DragFile>()
|
||||
private readonly pending = new Set<Promise<FileDragHandle>>()
|
||||
private readonly releasing = new Map<FileDragHandle, Promise<void>>()
|
||||
private stagedBytes = 0
|
||||
private disposed = false
|
||||
|
||||
/** @param temporaryDirectory - OS directory for private temporary exports. */
|
||||
constructor(private readonly temporaryDirectory: string) {}
|
||||
|
||||
/** @param bytes - complete file bytes. @param naming - desired export name. @returns an opaque preparation handle. */
|
||||
prepare(bytes: Uint8Array, naming: DragFileName): Promise<FileDragHandle> {
|
||||
if (this.disposed || bytes.byteLength > MAX_TOTAL_DRAG_FILE_BYTES - this.stagedBytes) {
|
||||
return Promise.reject(new Error('File drag temporary storage unavailable'))
|
||||
}
|
||||
this.stagedBytes += bytes.byteLength
|
||||
const operation = this.write(bytes, resolveDragFileName(naming))
|
||||
this.pending.add(operation)
|
||||
void operation.then(() => { this.pending.delete(operation) }, () => { this.pending.delete(operation) })
|
||||
return operation
|
||||
}
|
||||
|
||||
private async write(bytes: Uint8Array, name: string): Promise<FileDragHandle> {
|
||||
let directory: string
|
||||
try { directory = await mkdtemp(join(this.temporaryDirectory, 'dsh-file-drag-')) }
|
||||
catch (error) { this.stagedBytes -= bytes.byteLength; throw error }
|
||||
const handle = randomUUID() as FileDragHandle
|
||||
const file: DragFile = { directory, path: join(directory, name), bytes: bytes.byteLength, used: false, ready: false }
|
||||
this.files.set(handle, file)
|
||||
try {
|
||||
await writeFile(file.path, bytes, { flag: 'wx', mode: 0o600 })
|
||||
if (this.disposed) throw new Error('File transfer closed')
|
||||
file.ready = true
|
||||
return handle
|
||||
} catch (error) {
|
||||
await this.remove(handle, file)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an authorized source without owning its content.
|
||||
* @param path - verified local source.
|
||||
* @returns borrowed handle.
|
||||
*/
|
||||
borrow(path: string): FileDragHandle {
|
||||
if (this.disposed) throw new Error('File transfer closed')
|
||||
const handle = randomUUID() as FileDragHandle
|
||||
this.files.set(handle, { path, bytes: 0, used: false, ready: true })
|
||||
return handle
|
||||
}
|
||||
|
||||
/** @param handle - preparation identity. @param retain - whether the OS accepted this file. @returns path only while ready and owned. */
|
||||
take(handle: unknown, retain = true): string | undefined {
|
||||
if (typeof handle !== 'string') return undefined
|
||||
const key = handle as FileDragHandle
|
||||
const file = this.files.get(key)
|
||||
if (file === undefined || !file.ready || this.disposed || this.releasing.has(key)) return undefined
|
||||
if (retain && !file.used) {
|
||||
file.used = true
|
||||
file.timer = setTimeout(() => {
|
||||
void this.remove(key, file).catch((error: unknown) => { console.error(error) })
|
||||
}, DELIVERED_FILE_RETENTION_MS)
|
||||
file.timer.unref()
|
||||
}
|
||||
return file.path
|
||||
}
|
||||
|
||||
/** Release unused preparations immediately; used exports have a bounded read grace period. @param handle - preparation identity. */
|
||||
async release(handle: unknown): Promise<void> {
|
||||
if (typeof handle !== 'string') return
|
||||
const key = handle as FileDragHandle
|
||||
const file = this.files.get(key)
|
||||
if (file === undefined || file.used) return
|
||||
await this.remove(key, file)
|
||||
}
|
||||
|
||||
private remove(handle: FileDragHandle, file: DragFile): Promise<void> {
|
||||
const existing = this.releasing.get(handle)
|
||||
if (existing !== undefined) return existing
|
||||
clearTimeout(file.timer)
|
||||
file.ready = false
|
||||
const operation = (file.directory === undefined ? Promise.resolve() : rm(file.directory, { recursive: true, force: true })).then(() => {
|
||||
this.files.delete(handle)
|
||||
this.stagedBytes -= file.bytes
|
||||
}).finally(() => { this.releasing.delete(handle) })
|
||||
this.releasing.set(handle, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
/** Stop preparation, await every write/release, and retry all tracked removals before reporting failure. */
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
await Promise.allSettled(this.pending)
|
||||
await Promise.allSettled(this.releasing.values())
|
||||
const results = await Promise.allSettled([...this.files].map(([handle, file]) => this.remove(handle, file)))
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/** Native copy drags authorized only for the product main frame and the active Host. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, statSync, type Stats } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
import { app, ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type NativeImage } from 'electron'
|
||||
import { resolveDragFileName, type DesktopFileSource, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
|
||||
import { DESKTOP_IPC } from './ipc.ts'
|
||||
import { DragFiles } from './file-drag-files.ts'
|
||||
|
||||
interface LocalResource { readonly path: string; readonly size: number; readonly sha256: string }
|
||||
|
||||
function requestOf(value: unknown): DesktopFileSource {
|
||||
if (typeof value !== 'object' || value === null || !('kind' in value)
|
||||
|| !('mediaType' in value) || typeof value.mediaType !== 'string'
|
||||
|| ('name' in value && value.name !== undefined && typeof value.name !== 'string')
|
||||
|| ('outputExtension' in value && value.outputExtension !== undefined && typeof value.outputExtension !== 'string')) throw new Error('Invalid file drag request')
|
||||
const naming = {
|
||||
mediaType: value.mediaType, name: 'name' in value ? value.name as string | undefined : undefined,
|
||||
outputExtension: 'outputExtension' in value ? value.outputExtension as string | undefined : undefined,
|
||||
}
|
||||
if (value.kind === 'bytes' && 'bytes' in value && value.bytes instanceof Uint8Array) return { kind: 'bytes', bytes: value.bytes, ...naming }
|
||||
if (value.kind === 'resource' && 'address' in value && typeof value.address === 'string'
|
||||
&& (!('sha256' in value) || (typeof value.sha256 === 'string' && /^[a-f0-9]{64}$/u.test(value.sha256)))
|
||||
&& (!('size' in value) || (typeof value.size === 'number' && Number.isSafeInteger(value.size) && value.size >= 0))) {
|
||||
return { kind: 'resource', address: value.address, ...naming,
|
||||
...('sha256' in value ? { sha256: value.sha256 as string } : {}),
|
||||
...('size' in value ? { size: value.size as number } : {}) }
|
||||
}
|
||||
throw new Error('Invalid file drag request')
|
||||
}
|
||||
|
||||
function sameVersion(before: Stats, after: Stats): boolean {
|
||||
return after.isFile() && before.dev === after.dev && before.ino === after.ino
|
||||
&& before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs && before.size === after.size
|
||||
}
|
||||
|
||||
async function verifiedVersion(resource: LocalResource, signal: AbortSignal): Promise<Stats | undefined> {
|
||||
signal.throwIfAborted()
|
||||
const info = await stat(resource.path)
|
||||
if (!info.isFile() || info.size !== resource.size) return undefined
|
||||
const hash = createHash('sha256')
|
||||
const stream = createReadStream(resource.path, { signal })
|
||||
const chunks: AsyncIterable<unknown> = stream
|
||||
for await (const chunk of chunks) {
|
||||
if (!(chunk instanceof Uint8Array)) throw new Error('File stream returned non-binary data')
|
||||
hash.update(chunk)
|
||||
}
|
||||
const after = await stat(resource.path)
|
||||
return sameVersion(info, after) && hash.digest('hex') === resource.sha256 ? after : undefined
|
||||
}
|
||||
|
||||
async function cancellable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted()
|
||||
const cancelled = Promise.withResolvers<never>()
|
||||
const abort = (): void => { cancelled.reject(signal.reason) }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
try { return await Promise.race([operation, cancelled.promise]) }
|
||||
finally { signal.removeEventListener('abort', abort) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register file preparation and native dragging.
|
||||
* @param owner - shared product sender authorization, returning its window.
|
||||
* @param resolveResource - cancellable private Host authorization; renderer paths are never used directly.
|
||||
* @returns cleanup aborting resource reads and awaiting tracked writes/removals without waiting for the system icon service.
|
||||
*/
|
||||
export function installDesktopFileDrag(
|
||||
owner: (event: IpcMainInvokeEvent) => BrowserWindow,
|
||||
resolveResource: (address: string, signal: AbortSignal) => Promise<LocalResource | null>,
|
||||
): () => Promise<void> {
|
||||
let disposed = false
|
||||
const preparations = new Map<string, AbortController>()
|
||||
const files = new DragFiles(app.getPath('temp'))
|
||||
const pending = new Set<Promise<FileDragHandle | undefined>>()
|
||||
const icons = new Map<string, NativeImage>()
|
||||
const borrowed = new Map<string, { resource: LocalResource; version: Stats }>()
|
||||
const authorize = (event: IpcMainInvokeEvent): BrowserWindow => {
|
||||
if (disposed) throw new Error('File transfer closed')
|
||||
return owner(event)
|
||||
}
|
||||
const prepare = async (event: IpcMainInvokeEvent, value: unknown, signal: AbortSignal): Promise<FileDragHandle | undefined> => {
|
||||
authorize(event)
|
||||
const source = requestOf(value)
|
||||
let handle: FileDragHandle
|
||||
if (source.kind === 'bytes') handle = await files.prepare(source.bytes, source)
|
||||
else {
|
||||
const resource = await cancellable(resolveResource(source.address, signal), signal)
|
||||
authorize(event)
|
||||
if (resource === null || basename(resource.path) !== resolveDragFileName(source)
|
||||
|| (source.sha256 !== undefined && source.sha256 !== resource.sha256)
|
||||
|| (source.size !== undefined && source.size !== resource.size)) return undefined
|
||||
const version = await verifiedVersion(resource, signal)
|
||||
if (version === undefined) return undefined
|
||||
handle = files.borrow(resource.path)
|
||||
borrowed.set(handle, { resource, version })
|
||||
}
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
const file = files.take(handle, false)
|
||||
if (file === undefined) throw new Error('File transfer closed')
|
||||
const icon = await cancellable(app.getFileIcon(file, { size: 'normal' }), signal)
|
||||
authorize(event)
|
||||
icons.set(handle, icon)
|
||||
return handle
|
||||
} catch (error) {
|
||||
borrowed.delete(handle)
|
||||
await files.release(handle)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
ipcMain.handle(DESKTOP_IPC.fileDragPrepare, (event, source: unknown, requestId: unknown) => {
|
||||
authorize(event)
|
||||
if (typeof requestId !== 'string' || requestId.length === 0 || requestId.length > 128 || preparations.has(requestId)) throw new Error('Invalid file preparation identity')
|
||||
const abort = new AbortController()
|
||||
preparations.set(requestId, abort)
|
||||
const operation = prepare(event, source, abort.signal).finally(() => { preparations.delete(requestId) })
|
||||
pending.add(operation)
|
||||
void operation.then(() => { pending.delete(operation) }, () => { pending.delete(operation) })
|
||||
return operation
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.fileDragCancel, (event, requestId: unknown) => {
|
||||
authorize(event)
|
||||
if (typeof requestId === 'string') preparations.get(requestId)?.abort(new Error('File preparation cancelled'))
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.fileDragStart, (event, handle: unknown) => {
|
||||
const window = authorize(event)
|
||||
const icon = typeof handle === 'string' ? icons.get(handle) : undefined
|
||||
if (icon === undefined || typeof handle !== 'string') return false
|
||||
const file = files.take(handle, false)
|
||||
if (file === undefined) return false
|
||||
try {
|
||||
const original = borrowed.get(handle)
|
||||
if (original !== undefined && !sameVersion(original.version, statSync(original.resource.path))) throw new Error('File changed before dragging')
|
||||
authorize(event)
|
||||
window.webContents.startDrag({ file, icon })
|
||||
files.take(handle)
|
||||
icons.delete(handle)
|
||||
borrowed.delete(handle)
|
||||
return true
|
||||
} catch (_error) {
|
||||
icons.delete(handle)
|
||||
borrowed.delete(handle)
|
||||
void files.release(handle).catch((error: unknown) => { console.error(error) })
|
||||
return false
|
||||
}
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.fileDragRelease, async (event, handle: unknown) => {
|
||||
authorize(event)
|
||||
if (typeof handle === 'string') { icons.delete(handle); borrowed.delete(handle) }
|
||||
await files.release(handle)
|
||||
})
|
||||
return async () => {
|
||||
if (!disposed) {
|
||||
disposed = true
|
||||
for (const abort of preparations.values()) abort.abort(new Error('File transfer closed'))
|
||||
ipcMain.removeHandler(DESKTOP_IPC.fileDragPrepare)
|
||||
ipcMain.removeHandler(DESKTOP_IPC.fileDragCancel)
|
||||
ipcMain.removeHandler(DESKTOP_IPC.fileDragStart)
|
||||
ipcMain.removeHandler(DESKTOP_IPC.fileDragRelease)
|
||||
}
|
||||
await Promise.allSettled(pending)
|
||||
icons.clear()
|
||||
borrowed.clear()
|
||||
await files.dispose()
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ interface PlatformSessionEvent {
|
||||
}
|
||||
|
||||
type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { readonly type: 'shutdown-complete' } | {
|
||||
readonly type: 'file-drag-resource'
|
||||
readonly requestId: number
|
||||
readonly resource: { readonly path: string; readonly sha256: string; readonly size: number } | null
|
||||
readonly error?: string
|
||||
} | {
|
||||
readonly type: 'update-tasks'
|
||||
readonly requestId: number
|
||||
readonly active: boolean
|
||||
@@ -82,6 +87,13 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
|
||||
case 'update-tasks':
|
||||
return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean'
|
||||
&& (candidate.error === undefined || typeof candidate.error === 'string')
|
||||
case 'file-drag-resource': {
|
||||
const resource = candidate.resource
|
||||
return Number.isSafeInteger(candidate.requestId) && (candidate.error === undefined || typeof candidate.error === 'string')
|
||||
&& (resource === null || (typeof resource === 'object' && 'path' in resource && typeof resource.path === 'string'
|
||||
&& 'sha256' in resource && typeof resource.sha256 === 'string' && /^[a-f0-9]{64}$/u.test(resource.sha256)
|
||||
&& 'size' in resource && typeof resource.size === 'number' && Number.isSafeInteger(resource.size) && resource.size >= 0))
|
||||
}
|
||||
case 'quit-inspection':
|
||||
return Number.isSafeInteger(candidate.requestId) && typeof candidate.activeTasks === 'boolean'
|
||||
&& typeof candidate.scheduledTasks === 'boolean' && (candidate.error === undefined || typeof candidate.error === 'string')
|
||||
@@ -241,7 +253,7 @@ export class DesktopHostProcess {
|
||||
* an unanswered drain fails at the control-request deadline without authorizing installation.
|
||||
*/
|
||||
async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise<boolean> {
|
||||
const response = await this.control({ type: 'update-tasks', action }, 10_000, 'desktop update: task inspection timed out')
|
||||
const response = await this.control({ type: 'update-tasks', action }, 10_000, 'desktop update: task inspection timed out', 'desktop update')
|
||||
if (response.type !== 'update-tasks') throw new Error('desktop update: Host answered with a different control response')
|
||||
return response.active
|
||||
}
|
||||
@@ -252,29 +264,53 @@ export class DesktopHostProcess {
|
||||
* {@link QUIT_INSPECTION_DEADLINE_MS}, and the shell then asks before quitting.
|
||||
*/
|
||||
async inspectQuit(): Promise<DesktopQuitInspection> {
|
||||
const response = await this.control({ type: 'quit-inspection' }, QUIT_INSPECTION_DEADLINE_MS, 'desktop quit: inspection timed out')
|
||||
const response = await this.control({ type: 'quit-inspection' }, QUIT_INSPECTION_DEADLINE_MS, 'desktop quit: inspection timed out', 'desktop quit')
|
||||
if (response.type !== 'quit-inspection') throw new Error('desktop quit: Host answered with a different control response')
|
||||
return { activeTasks: response.activeTasks, scheduledTasks: response.scheduledTasks }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param address - Session-scoped file reference.
|
||||
* @param signal - preparation cancellation.
|
||||
* @returns privately authorized local resource, or null when nonlocal.
|
||||
*/
|
||||
async resolveDragResource(address: string, signal?: AbortSignal): Promise<
|
||||
{ readonly path: string; readonly sha256: string; readonly size: number } | null
|
||||
> {
|
||||
const response = await this.control({ type: 'file-drag-resource', address }, 12_000, 'File drag authorization timed out', 'file drag', signal)
|
||||
if (response.type !== 'file-drag-resource') throw new Error('Host returned a different control response')
|
||||
return response.resource
|
||||
}
|
||||
|
||||
private async control(
|
||||
request: { readonly type: 'update-tasks'; readonly action: 'inspect' | 'lock' | 'unlock' } | { readonly type: 'quit-inspection' },
|
||||
deadlineMs: number, deadlineMessage: string,
|
||||
request: { readonly type: 'update-tasks'; readonly action: 'inspect' | 'lock' | 'unlock' } | { readonly type: 'quit-inspection' }
|
||||
| { readonly type: 'file-drag-resource'; readonly address: string },
|
||||
deadlineMs: number, deadlineMessage: string, operation: string, signal?: AbortSignal,
|
||||
): Promise<DesktopHostControlResponse> {
|
||||
const child = this.child
|
||||
if (child === undefined || !child.connected || this.failureReported || this.stopping) {
|
||||
throw new Error(`${request.type === 'update-tasks' ? 'desktop update' : 'desktop quit'}: Host is unavailable`)
|
||||
throw new Error(`${operation}: Host is unavailable`)
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
const requestId = this.nextControlId++
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const abort = (): void => {
|
||||
if (request.type === 'file-drag-resource' && child.connected) child.send({ type: 'file-drag-cancel', requestId }, (_error) => {
|
||||
// The request still rejects locally if the Host has disconnected.
|
||||
})
|
||||
const reason: unknown = signal?.reason
|
||||
this.controlRequests.get(requestId)?.reject(reason instanceof Error ? reason : new Error(deadlineMessage))
|
||||
}
|
||||
try {
|
||||
return await new Promise<DesktopHostControlResponse>((resolve, reject) => {
|
||||
this.controlRequests.set(requestId, { resolve, reject })
|
||||
timer = setTimeout(() => { reject(new Error(deadlineMessage)) }, deadlineMs)
|
||||
timer = setTimeout(abort, deadlineMs)
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
child.send({ ...request, requestId }, (error) => { if (error !== null) reject(error) })
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', abort)
|
||||
this.controlRequests.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
||||
import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag'
|
||||
|
||||
/** IPC channel names kept private to the desktop application bundle. */
|
||||
export const DESKTOP_IPC = {
|
||||
@@ -21,6 +22,10 @@ export const DESKTOP_IPC = {
|
||||
browserRelease: 'dsh-desktop:browser-release',
|
||||
browserOpenRequested: 'dsh-desktop:browser-open-requested',
|
||||
directoryPick: 'dsh-desktop:directory-pick',
|
||||
fileDragPrepare: 'dsh-desktop:file-drag-prepare',
|
||||
fileDragCancel: 'dsh-desktop:file-drag-cancel',
|
||||
fileDragStart: 'dsh-desktop:file-drag-start',
|
||||
fileDragRelease: 'dsh-desktop:file-drag-release',
|
||||
deviceInfo: 'dsh-desktop:device-info',
|
||||
localeBootstrap: 'dsh-desktop:locale-bootstrap',
|
||||
localeChanged: 'dsh-desktop:locale-changed',
|
||||
@@ -73,6 +78,7 @@ export interface DesktopUpdatePresentation {
|
||||
export interface DshDesktopProductApi {
|
||||
readonly protocolVersion: 1
|
||||
readonly browser: DesktopBrowserBridge
|
||||
readonly fileDrag: DesktopFileDrag
|
||||
readonly keyboard: DesktopKeyboardApi
|
||||
readonly shortcuts: DesktopShortcutsApi
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ import { resolveDesktopPaths } from './paths.ts'
|
||||
import { DesktopProjectManager } from './project-manager.ts'
|
||||
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
|
||||
import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
|
||||
import { installDesktopFileDrag } from './file-drag.ts'
|
||||
import { installDesktopDirectoryPicker } from './directory-picker.ts'
|
||||
import { installMicrophonePermissions } from './microphone-permissions.ts'
|
||||
import { DesktopBackendController } from './backend-controller.ts'
|
||||
@@ -345,6 +346,25 @@ async function main(): Promise<void> {
|
||||
let workspaceRecovery: Promise<void> | undefined
|
||||
let mainWindow: BrowserWindow | undefined
|
||||
let welcomeWindow: BrowserWindow | undefined
|
||||
const installFileDrag = (): (() => Promise<void>) => installDesktopFileDrag(
|
||||
event => assertProductSender(event),
|
||||
(address, signal) => {
|
||||
const host = backend.host
|
||||
if (host === undefined) return Promise.reject(new Error('File drag Host unavailable'))
|
||||
return host.resolveDragResource(address, signal)
|
||||
},
|
||||
)
|
||||
const fileDragCleanups = new Set([installFileDrag()])
|
||||
let fileDragStopped = false
|
||||
const disposeFileDrag = async (): Promise<void> => {
|
||||
fileDragStopped = true
|
||||
const results = await Promise.allSettled([...fileDragCleanups].map(async (release) => {
|
||||
await release()
|
||||
fileDragCleanups.delete(release)
|
||||
}))
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
}
|
||||
let enteredWorkspace = false
|
||||
// NSIS passes --updated when it launches the application after installation.
|
||||
let raiseAfterUpdate = process.platform === 'win32' && process.argv.includes('--updated')
|
||||
@@ -419,12 +439,13 @@ async function main(): Promise<void> {
|
||||
let returnedAttempt: string | undefined
|
||||
let pendingWelcomeNotice: WelcomeNotice | undefined
|
||||
let previousAccountStatus: string | undefined
|
||||
const assertProductSender = (event: IpcMainInvokeEvent): void => {
|
||||
const assertProductSender = (event: IpcMainInvokeEvent): BrowserWindow => {
|
||||
assertDesktopSender(event, ['app'])
|
||||
if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents
|
||||
|| event.senderFrame === null || event.senderFrame !== mainWindow.webContents.mainFrame) {
|
||||
throw new Error('dsh desktop: rejected IPC from an unowned renderer')
|
||||
}
|
||||
return mainWindow
|
||||
}
|
||||
let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
|
||||
const navigateMain = (url: string): Promise<void> => {
|
||||
@@ -511,6 +532,7 @@ async function main(): Promise<void> {
|
||||
},
|
||||
updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action),
|
||||
inspectQuit: () => host.inspectQuit(),
|
||||
resolveDragResource: (address: string, signal?: AbortSignal) => host.resolveDragResource(address, signal),
|
||||
}
|
||||
}, (state) => {
|
||||
if (state.phase === 'error') reportFatal(state.failure, 'host')
|
||||
@@ -538,6 +560,10 @@ async function main(): Promise<void> {
|
||||
window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state))
|
||||
}
|
||||
if (state.phase === 'error' && state.failedOperation !== 'check') {
|
||||
if (state.failedOperation === 'install' && fileDragStopped && !quitting) {
|
||||
fileDragCleanups.add(installFileDrag())
|
||||
fileDragStopped = false
|
||||
}
|
||||
const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting
|
||||
shellInstallerOwnsQuit = false
|
||||
updateStoppedHost = false
|
||||
@@ -622,6 +648,7 @@ async function main(): Promise<void> {
|
||||
mandatoryUI?.preparingRestart(stillActive)
|
||||
// The embedded Platform document holds credentials issued by the Host that is about to stop.
|
||||
await platformView.closeAndWait()
|
||||
await disposeFileDrag().catch((error: unknown) => { console.error(error) })
|
||||
requireCleanStop = true
|
||||
updateStopFailure = undefined
|
||||
await backend.stop()
|
||||
@@ -1263,7 +1290,8 @@ async function main(): Promise<void> {
|
||||
mandatoryUI?.dispose()
|
||||
void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(),
|
||||
// A Platform cleanup failure is logged without cutting the remaining Host shutdown short.
|
||||
platformView.dispose().catch((error: unknown) => { console.error(error) })])
|
||||
platformView.dispose().catch((error: unknown) => { console.error(error) }),
|
||||
disposeFileDrag().catch((error: unknown) => { console.error(error) })])
|
||||
.catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
|
||||
}
|
||||
app.on('before-quit', (event) => {
|
||||
|
||||
@@ -9,11 +9,23 @@ import { syncNativeTheme } from './preload-theme.ts'
|
||||
import { syncWindowsAppearance } from './preload-windows.ts'
|
||||
import { installMandatoryUpdateOverlay } from './preload-mandatory-overlay.ts'
|
||||
import { createDesktopBrowserBridge } from './preload-browser.ts'
|
||||
import type { FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
|
||||
|
||||
function createProductApi(): DshDesktopProductApi {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
browser: createDesktopBrowserBridge(),
|
||||
fileDrag: {
|
||||
prepare: (source, requestId) => ipcRenderer.invoke(DESKTOP_IPC.fileDragPrepare, source, requestId) as
|
||||
Promise<FileDragHandle | undefined>,
|
||||
cancel: (requestId) => { void ipcRenderer.invoke(DESKTOP_IPC.fileDragCancel, requestId).catch((_error: unknown) => {
|
||||
// Main-process shutdown cancels any remaining preparations.
|
||||
}) },
|
||||
start: handle => ipcRenderer.invoke(DESKTOP_IPC.fileDragStart, handle) as Promise<boolean>,
|
||||
release: (handle) => { void ipcRenderer.invoke(DESKTOP_IPC.fileDragRelease, handle).catch((_error: unknown) => {
|
||||
// Main-process shutdown also releases every prepared file.
|
||||
}) },
|
||||
},
|
||||
deviceInfo: () => ipcRenderer.invoke(DESKTOP_IPC.deviceInfo) as Promise<string>,
|
||||
keyboard: {
|
||||
closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise<void>,
|
||||
|
||||
@@ -22,9 +22,8 @@ import {
|
||||
import type { DesktopPaths } from './paths.ts'
|
||||
import type { DesktopRelease } from './release.ts'
|
||||
import { readDesktopRuntime } from './runtime-tree.ts'
|
||||
import {
|
||||
initProfile, PROFILE_TEMPLATES, removeLinkProjections, sanitizeProfile, type ProfileTemplate,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { initProfile, removeLinkProjections, sanitizeProfile } from '@deepseek-ai/dsh-app-boot'
|
||||
import { PROFILE_TEMPLATES, type ProfileTemplate } from '@deepseek-ai/dsh-official-bundles'
|
||||
|
||||
const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
|
||||
const DSH_PACKAGE = '@deepseek-ai/dsh'
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { DragFiles } from '../src/file-drag-files.ts'
|
||||
|
||||
async function files() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-drag-test-'))
|
||||
const store = new DragFiles(root)
|
||||
onTestFinished(async () => { await store.dispose(); await rm(root, { recursive: true, force: true }) })
|
||||
return { root, store }
|
||||
}
|
||||
|
||||
it('exports original bytes and keeps a used file readable until disposal', async () => {
|
||||
const { root, store } = await files()
|
||||
const bytes = new Uint8Array([137, 80, 78, 71])
|
||||
const handle = await store.prepare(bytes, { name: '../../original.png', mediaType: 'image/png' })
|
||||
const path = store.take(handle)!
|
||||
expect(basename(path)).toBe('_.._original.png')
|
||||
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
|
||||
await store.release(handle)
|
||||
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
|
||||
await store.dispose()
|
||||
expect(await readdir(root)).toEqual([])
|
||||
expect(store.take(handle)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts non-image and unknown types while rejecting writes after disposal', async () => {
|
||||
const { store } = await files()
|
||||
for (const [name, mediaType] of [['报告.pdf', 'application/pdf'], ['custom.asset', 'application/x-unknown']]) {
|
||||
const bytes = new Uint8Array([37, 80, 68, 70])
|
||||
const handle = await store.prepare(bytes, { name, mediaType: mediaType! })
|
||||
const path = store.take(handle, false)!
|
||||
expect(basename(path)).toBe(name)
|
||||
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
|
||||
}
|
||||
expect(store.take('foreign')).toBeUndefined()
|
||||
await store.dispose()
|
||||
await expect(store.prepare(new Uint8Array([1]), { name: 'x.png', mediaType: 'image/png' })).rejects.toThrow('File drag temporary storage unavailable')
|
||||
})
|
||||
|
||||
it('releases unused preparations and waits for a concurrent write before cleanup', async () => {
|
||||
const { root, store } = await files()
|
||||
const handle = await store.prepare(new Uint8Array([1]), { name: 'x.png', mediaType: 'image/png' })
|
||||
await store.release(handle)
|
||||
expect(await readdir(root)).toEqual([])
|
||||
const pending = store.prepare(new Uint8Array([2]), { name: 'y.png', mediaType: 'image/png' })
|
||||
const rejected = expect(pending).rejects.toThrow('File transfer closed')
|
||||
await store.dispose()
|
||||
await rejected
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['CON', 'prn', 'AUX', 'NUL', 'COM1', 'LPT9', 'COM¹', 'CON.extra'])('sanitizes the Windows device filename %s', async (name) => {
|
||||
const { store } = await files()
|
||||
const handle = await store.prepare(new Uint8Array([1]), { name: `${name}.png`, mediaType: 'image/png' })
|
||||
expect(basename(store.take(handle, false)!)).toBe(`_${name}.png`)
|
||||
})
|
||||
|
||||
it('bounds all retained bytes, including pending writes, and frees unused capacity', async () => {
|
||||
const { store } = await files()
|
||||
const pending = store.prepare(new Uint8Array(64 * 1024 * 1024), { name: 'large.png', mediaType: 'image/png' })
|
||||
await expect(store.prepare(new Uint8Array([1]), { name: 'extra.png', mediaType: 'image/png' })).rejects.toThrow('File drag temporary storage unavailable')
|
||||
const handle = await pending
|
||||
await store.release(handle)
|
||||
await expect(store.prepare(new Uint8Array([1]), { name: 'extra.png', mediaType: 'image/png' })).resolves.toEqual(expect.any(String))
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
vi.mock('node:fs/promises', async importOriginal => ({
|
||||
...await importOriginal<typeof import('node:fs/promises')>(), rm: vi.fn(), writeFile: vi.fn(),
|
||||
}))
|
||||
const fs = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises')
|
||||
const { DragFiles } = await import('../src/file-drag-files.ts')
|
||||
afterEach(() => { vi.mocked(rm).mockReset(); vi.mocked(writeFile).mockReset(); vi.useRealTimers() })
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-image-release-'))
|
||||
const store = new DragFiles(root)
|
||||
vi.mocked(rm).mockImplementation(fs.rm)
|
||||
vi.mocked(writeFile).mockImplementation(fs.writeFile)
|
||||
onTestFinished(async () => {
|
||||
vi.mocked(rm).mockImplementation(fs.rm)
|
||||
await store.dispose()
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
})
|
||||
const handle = await store.prepare(new Uint8Array([1]), { name: 'image.png', mediaType: 'image/png' })
|
||||
return { root, store, handle }
|
||||
}
|
||||
|
||||
it('waits for an in-flight release before disposal completes', async () => {
|
||||
const { root, store, handle } = await fixture()
|
||||
const barrier = Promise.withResolvers<undefined>()
|
||||
vi.mocked(rm).mockImplementationOnce(async (path, options) => { await barrier.promise; await fs.rm(path, options) })
|
||||
const releasing = store.release(handle)
|
||||
const secondRelease = store.release(handle)
|
||||
expect(store.take(handle, false)).toBeUndefined()
|
||||
let stopped = false
|
||||
const disposing = store.dispose().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
expect(await readdir(root)).toHaveLength(1)
|
||||
barrier.resolve(undefined)
|
||||
await Promise.all([releasing, secondRelease, disposing])
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('retains a partial write whose immediate cleanup fails for disposal to retry', async () => {
|
||||
const { root, store } = await fixture()
|
||||
await store.prepare(new Uint8Array(0), { name: 'empty', mediaType: '' })
|
||||
vi.mocked(writeFile).mockImplementationOnce(async (path, data, options) => {
|
||||
await fs.writeFile(path, data, options)
|
||||
throw new Error('disk full after partial write')
|
||||
})
|
||||
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
|
||||
await expect(store.prepare(new Uint8Array(64 * 1024 * 1024 - 1), { name: 'partial.pdf', mediaType: 'application/pdf' })).rejects.toThrow('file busy')
|
||||
await expect(store.prepare(new Uint8Array([2]), { name: 'later', mediaType: '' })).rejects.toThrow('File drag temporary storage unavailable')
|
||||
await store.dispose()
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('reclaims delivered temporary bytes after the receiver grace period', async () => {
|
||||
const { root, store, handle } = await fixture()
|
||||
vi.useFakeTimers()
|
||||
store.take(handle)
|
||||
await store.release(handle)
|
||||
expect(await readdir(root)).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await store.dispose()
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('retains a failed release for the shutdown cleanup to retry', async () => {
|
||||
const { root, store, handle } = await fixture()
|
||||
const path = store.take(handle, false)!
|
||||
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
|
||||
await expect(store.release(handle)).rejects.toThrow('file busy')
|
||||
expect(new Uint8Array(await readFile(path))).toEqual(new Uint8Array([1]))
|
||||
await store.dispose()
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('waits for all removals before reporting a shutdown cleanup failure', async () => {
|
||||
const { store } = await fixture()
|
||||
await store.prepare(new Uint8Array([2]), { name: 'second.png', mediaType: 'image/png' })
|
||||
const barrier = Promise.withResolvers<undefined>()
|
||||
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
|
||||
.mockImplementationOnce(async (path, options) => { await barrier.promise; await fs.rm(path, options) })
|
||||
let settled = false
|
||||
const disposing = store.dispose().finally(() => { settled = true })
|
||||
const rejected = expect(disposing).rejects.toThrow('file busy')
|
||||
await vi.waitFor(() => { expect(rm).toHaveBeenCalledTimes(2) })
|
||||
expect(settled).toBe(false)
|
||||
barrier.resolve(undefined)
|
||||
await rejected
|
||||
await store.dispose()
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { BrowserWindow, IpcMainInvokeEvent, NativeImage, WebContents, WebFrameMain } from 'electron'
|
||||
import { DESKTOP_IPC, assertDesktopSender } from '../src/ipc.ts'
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
handle: vi.fn(), removeHandler: vi.fn(), getPath: vi.fn(), getFileIcon: vi.fn(),
|
||||
}))
|
||||
vi.mock('electron', () => ({ app: electron, ipcMain: electron }))
|
||||
const { installDesktopFileDrag } = await import('../src/file-drag.ts')
|
||||
afterEach(() => { vi.resetAllMocks() })
|
||||
|
||||
type Handler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-native-file-drag-'))
|
||||
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
electron.getPath.mockReturnValue(root)
|
||||
const icon = {} as NativeImage
|
||||
electron.getFileIcon.mockResolvedValue(icon)
|
||||
const frame = Object.assign({} as WebFrameMain, { url: 'dsh-app://app/' })
|
||||
const window = Object.assign({} as BrowserWindow, { isDestroyed: () => false,
|
||||
webContents: Object.assign({} as WebContents, { mainFrame: frame, startDrag: vi.fn() }) })
|
||||
let current: BrowserWindow | undefined = window
|
||||
const resolve = vi.fn<Parameters<typeof installDesktopFileDrag>[1]>(async () => null)
|
||||
const owner = (event: IpcMainInvokeEvent): BrowserWindow => {
|
||||
assertDesktopSender(event, ['app'])
|
||||
if (current === undefined || event.sender !== current.webContents || event.senderFrame !== frame) throw new Error('Unowned file drag request')
|
||||
return current
|
||||
}
|
||||
const dispose = installDesktopFileDrag(owner, resolve)
|
||||
onTestFinished(dispose)
|
||||
const handlers = new Map<string, Handler>(electron.handle.mock.calls.map(([channel, handler]) => [channel, handler]))
|
||||
const event = Object.assign({} as IpcMainInvokeEvent, { sender: window.webContents, senderFrame: frame })
|
||||
const call = (channel: string, ...args: unknown[]) => Promise.resolve().then(() => {
|
||||
const identity = channel === DESKTOP_IPC.fileDragPrepare && args.length === 1 ? [randomUUID()] : []
|
||||
return (handlers.get(channel) as Handler)(event, ...args, ...identity)
|
||||
})
|
||||
const prepare = (bytes: Uint8Array, name = 'original.pdf', mediaType = 'application/pdf') => call(DESKTOP_IPC.fileDragPrepare, { kind: 'bytes', bytes, name, mediaType })
|
||||
return { root, window, frame, event, icon, call, prepare, dispose, resolve, detach: () => { current = undefined } }
|
||||
}
|
||||
|
||||
it('delivers original PDF bytes and filename through native copy drag', async () => {
|
||||
const f = await fixture()
|
||||
const bytes = new TextEncoder().encode('%PDF-1.4\noriginal\n%%EOF')
|
||||
const handle = await f.prepare(bytes, '报告.pdf')
|
||||
expect(f.window.webContents.startDrag).not.toHaveBeenCalled()
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(true)
|
||||
const [request] = f.window.webContents.startDrag.mock.calls[0] as [{ file: string; icon: NativeImage }]
|
||||
expect(request.icon).toBe(f.icon)
|
||||
expect(request.file).toContain('报告.pdf')
|
||||
expect(new Uint8Array(await readFile(request.file))).toEqual(bytes)
|
||||
await f.call(DESKTOP_IPC.fileDragRelease, handle)
|
||||
expect(new Uint8Array(await readFile(request.file))).toEqual(bytes)
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(false)
|
||||
await f.dispose()
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
})
|
||||
|
||||
it('uses shared sender authorization and releases failed native starts', async () => {
|
||||
const f = await fixture()
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, 'foreign')).toBe(false)
|
||||
const handle = await f.prepare(new Uint8Array([1]))
|
||||
f.window.webContents.startDrag.mockImplementationOnce(() => { throw new Error('OS rejected drag') })
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(false)
|
||||
await vi.waitFor(async () => { expect(await readdir(f.root)).toEqual([]) })
|
||||
f.frame.url = 'https://other.test/'
|
||||
await expect(f.call(DESKTOP_IPC.fileDragStart, handle)).rejects.toThrow('unowned renderer')
|
||||
f.frame.url = 'dsh-app://app/'
|
||||
Object.assign(f.event, { senderFrame: { url: 'dsh-app://app/' } })
|
||||
await expect(f.call(DESKTOP_IPC.fileDragStart, handle)).rejects.toThrow('Unowned file drag request')
|
||||
Object.assign(f.event, { senderFrame: f.frame, sender: {} })
|
||||
await expect(f.call(DESKTOP_IPC.fileDragStart, handle)).rejects.toThrow('Unowned file drag request')
|
||||
f.detach()
|
||||
await expect(f.call(DESKTOP_IPC.fileDragStart, handle)).rejects.toThrow('Unowned file drag request')
|
||||
})
|
||||
|
||||
it('cancels icon preparation on shutdown without waiting for the icon service', async () => {
|
||||
const f = await fixture()
|
||||
const pending = Promise.withResolvers<NativeImage>()
|
||||
electron.getFileIcon.mockReturnValue(pending.promise)
|
||||
const rejected = expect(f.prepare(new Uint8Array([1]))).rejects.toThrow('File transfer closed')
|
||||
await vi.waitFor(() => { expect(electron.getFileIcon).toHaveBeenCalledOnce() })
|
||||
await f.dispose()
|
||||
await rejected
|
||||
pending.resolve(f.icon)
|
||||
await pending.promise
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels stalled icon preparation and returns its storage budget before the icon completes', async () => {
|
||||
const f = await fixture()
|
||||
const icon = Promise.withResolvers<NativeImage>()
|
||||
electron.getFileIcon.mockReturnValueOnce(icon.promise)
|
||||
const preparing = f.call(DESKTOP_IPC.fileDragPrepare,
|
||||
{ kind: 'bytes', bytes: new Uint8Array(64 * 1024 * 1024), name: 'large.pdf', mediaType: 'application/pdf' }, 'cancelled')
|
||||
const rejected = expect(preparing).rejects.toThrow('cancelled')
|
||||
await vi.waitFor(() => { expect(electron.getFileIcon).toHaveBeenCalledOnce() })
|
||||
await f.call(DESKTOP_IPC.fileDragCancel, 'cancelled')
|
||||
await rejected
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
const next = await f.prepare(new Uint8Array([2]))
|
||||
expect(next).toEqual(expect.any(String))
|
||||
icon.resolve(f.icon)
|
||||
await icon.promise
|
||||
})
|
||||
|
||||
it('aborts pending Host authorization when the bridge closes', async () => {
|
||||
const f = await fixture()
|
||||
const resource = Promise.withResolvers<null>()
|
||||
f.resolve.mockReturnValue(resource.promise)
|
||||
const preparing = f.call(DESKTOP_IPC.fileDragPrepare,
|
||||
{ kind: 'resource', address: 'dsh-resource://file/session/s/file.pdf', name: 'file.pdf', mediaType: 'application/pdf' })
|
||||
const rejected = expect(preparing).rejects.toThrow('File transfer closed')
|
||||
await vi.waitFor(() => { expect(f.resolve).toHaveBeenCalledOnce() })
|
||||
const signal = f.resolve.mock.calls[0]![1]
|
||||
await f.dispose()
|
||||
await rejected
|
||||
expect(signal.aborted).toBe(true)
|
||||
resource.resolve(null)
|
||||
await resource.promise
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
})
|
||||
|
||||
it('borrows only authorized unchanged files with the requested export name, never deleting the source', async () => {
|
||||
const f = await fixture()
|
||||
const bytes = Buffer.from('original PDF bytes')
|
||||
const path = join(f.root, 'report.pdf')
|
||||
await writeFile(path, bytes)
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
||||
f.resolve.mockResolvedValue({ path, size: bytes.length, sha256 })
|
||||
const request = { kind: 'resource', address: 'dsh-resource://file/session/s/report.pdf', name: 'report.pdf', mediaType: 'application/pdf', sha256, size: bytes.length }
|
||||
const handle = await f.call(DESKTOP_IPC.fileDragPrepare, request)
|
||||
expect(f.resolve).toHaveBeenCalledWith(request.address, expect.any(AbortSignal))
|
||||
expect(await readdir(f.root)).toEqual(['report.pdf'])
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(true)
|
||||
expect(f.window.webContents.startDrag).toHaveBeenCalledWith({ file: path, icon: f.icon })
|
||||
await f.dispose()
|
||||
expect(await readFile(path)).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('requests a copy for renamed, changed, and nonlocal resources', async () => {
|
||||
const f = await fixture()
|
||||
const path = join(f.root, 'report.pdf')
|
||||
const bytes = Buffer.from('PDF')
|
||||
await writeFile(path, bytes)
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
||||
f.resolve.mockResolvedValue({ path, size: bytes.length, sha256 })
|
||||
const request = { kind: 'resource', address: 'dsh-resource://file/session/s/report.pdf', name: 'renamed.pdf', mediaType: 'application/pdf' }
|
||||
expect(await f.call(DESKTOP_IPC.fileDragPrepare, request)).toBeUndefined()
|
||||
expect(await f.call(DESKTOP_IPC.fileDragPrepare, { ...request, name: 'report.pdf', sha256: '0'.repeat(64) })).toBeUndefined()
|
||||
expect(await f.call(DESKTOP_IPC.fileDragPrepare, { ...request, name: 'report.pdf', size: 100 })).toBeUndefined()
|
||||
const handle = await f.call(DESKTOP_IPC.fileDragPrepare, { ...request, name: 'report.pdf' })
|
||||
await writeFile(path, 'changed')
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(false)
|
||||
f.resolve.mockResolvedValue(null)
|
||||
expect(await f.call(DESKTOP_IPC.fileDragPrepare, request)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([null, {}, { kind: 'bytes', bytes: '/private/path', mediaType: 'text/plain' }, { kind: 'resource', address: '/private/path', mediaType: 1 }])('rejects malformed wire requests', async (value) => {
|
||||
const f = await fixture()
|
||||
await expect(f.call(DESKTOP_IPC.fileDragPrepare, value)).rejects.toThrow('Invalid file drag request')
|
||||
})
|
||||
@@ -30,6 +30,21 @@ server.listen(0, '127.0.0.1', () => {
|
||||
process.send({ type: 'ready', url: 'http://127.0.0.1:' + server.address().port + '/?token=fixture' })
|
||||
})
|
||||
process.on('message', message => {
|
||||
if (message.type === 'file-drag-cancel') {
|
||||
process.send({ type: 'file-drag-resource', requestId: message.requestId, resource: null })
|
||||
return
|
||||
}
|
||||
if (message.type === 'file-drag-resource') {
|
||||
if (message.address === 'pending') return
|
||||
if (message.address === 'invalid') {
|
||||
process.send({ type: 'file-drag-resource', requestId: message.requestId, resource: { path: '/invalid', sha256: 'bad', size: -1 } })
|
||||
return
|
||||
}
|
||||
process.send({ type: 'file-drag-resource', requestId: message.requestId,
|
||||
resource: message.address === 'remote' ? null : { path: '/authorized/report.pdf', sha256: 'a'.repeat(64), size: 42 },
|
||||
...(message.address === 'denied' ? { error: 'File read denied' } : {}) })
|
||||
return
|
||||
}
|
||||
if (message.type === 'update-tasks') {
|
||||
process.send({ type: 'update-tasks', requestId: message.requestId, active: message.action === 'lock' })
|
||||
return
|
||||
@@ -73,6 +88,32 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('desktop host process', () => {
|
||||
it('correlates authorized file resources and rejects Host denial over private IPC', async () => {
|
||||
const host = hostProcess(projectWithHost())
|
||||
await host.start()
|
||||
expect(await Promise.all([host.resolveDragResource('local'), host.resolveDragResource('remote')]))
|
||||
.toEqual([{ path: '/authorized/report.pdf', sha256: 'a'.repeat(64), size: 42 }, null])
|
||||
await expect(host.resolveDragResource('denied')).rejects.toThrow('File read denied')
|
||||
await host.stop(true)
|
||||
await expect(host.resolveDragResource('local')).rejects.toThrow('file drag: Host is unavailable')
|
||||
})
|
||||
|
||||
it('cancels a pending resource request without invalidating later Host requests', async () => {
|
||||
const host = hostProcess(projectWithHost())
|
||||
await host.start()
|
||||
const abort = new AbortController()
|
||||
const pending = host.resolveDragResource('pending', abort.signal)
|
||||
const rejected = expect(pending).rejects.toThrow('cancelled')
|
||||
abort.abort(new Error('cancelled'))
|
||||
await rejected
|
||||
await expect(host.resolveDragResource('local')).resolves.toMatchObject({ path: '/authorized/report.pdf' })
|
||||
})
|
||||
|
||||
it('rejects malformed file authorization responses and terminates the Host', async () => {
|
||||
const host = hostProcess(projectWithHost())
|
||||
await host.start()
|
||||
await expect(host.resolveDragResource('invalid')).rejects.toThrow('invalid IPC event')
|
||||
})
|
||||
it('correlates task inspections and admission changes over private IPC', async () => {
|
||||
const host = hostProcess(projectWithHost())
|
||||
await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable')
|
||||
|
||||
@@ -16,6 +16,9 @@ import { writeCrashReport } from '../src/crash-report.ts'
|
||||
type InvokeEvent = { sender?: unknown; senderFrame: { url: string } }
|
||||
type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown
|
||||
|
||||
const fileDrag = vi.hoisted(() => ({ dispose: vi.fn<() => Promise<void>>(), install: vi.fn() }))
|
||||
vi.mock('../src/file-drag.ts', () => ({ installDesktopFileDrag: () => { fileDrag.install(); return fileDrag.dispose } }))
|
||||
|
||||
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
|
||||
// Report persistence has its own unit tests; here it resolves within microtasks so the fatal
|
||||
// dialog never outlives the test that triggered it.
|
||||
@@ -273,6 +276,8 @@ const harness = await vi.hoisted(async () => {
|
||||
navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred()
|
||||
policyBlocked = deferred()
|
||||
embeddedPolicy = undefined
|
||||
fileDrag.install.mockReset()
|
||||
fileDrag.dispose.mockReset().mockResolvedValue(undefined)
|
||||
platformDisposeDeferred = undefined
|
||||
platformCloseDeferred = undefined
|
||||
},
|
||||
@@ -1518,6 +1523,45 @@ describe('desktop main startup', () => {
|
||||
expect(harness.app.quit).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports file cleanup failure without ending Host shutdown early', async () => {
|
||||
const host = await readyForUpdate()
|
||||
const failure = new Error('image cleanup failed')
|
||||
fileDrag.dispose.mockRejectedValueOnce(failure)
|
||||
harness.app.quit()
|
||||
await host.stopping.promise
|
||||
await vi.waitFor(() => { expect(console.error).toHaveBeenCalledWith(failure) })
|
||||
expect(harness.app.quit).toHaveBeenCalledOnce()
|
||||
host.exited.resolve()
|
||||
await harness.quitCompleted.promise
|
||||
expect(harness.app.quit).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('waits for image cleanup before stopping the Host for installation', async () => {
|
||||
const host = await readyForUpdate()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
fileDrag.dispose.mockReturnValueOnce(cleanup.promise)
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
const preparing = harness.prepareUpdate()
|
||||
await vi.waitFor(() => { expect(fileDrag.dispose).toHaveBeenCalledOnce() })
|
||||
expect(host.stop).not.toHaveBeenCalled()
|
||||
cleanup.resolve(undefined)
|
||||
await host.stopping.promise
|
||||
host.exited.resolve()
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('continues installation after reporting file cleanup failure', async () => {
|
||||
const host = await readyForUpdate()
|
||||
const failure = new Error('temporary file busy')
|
||||
fileDrag.dispose.mockRejectedValueOnce(failure)
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
const preparing = harness.prepareUpdate()
|
||||
await host.stopping.promise
|
||||
expect(console.error).toHaveBeenCalledWith(failure)
|
||||
host.exited.resolve()
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => {
|
||||
const host = await readyForUpdate()
|
||||
const window = harness.windows[0]!
|
||||
@@ -2018,6 +2062,7 @@ describe('desktop main startup', () => {
|
||||
const restarted = harness.nextHostStart()
|
||||
harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Installer failed' })
|
||||
await restarted
|
||||
expect(fileDrag.install).toHaveBeenCalledTimes(2)
|
||||
const replacement = harness.hosts[1]!
|
||||
replacement.url = 'http://127.0.0.1:3099/?token=replacement'
|
||||
if (mandatory) replacement.updateTasks.mockResolvedValue(true)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { DesktopFileDrag, FileDragHandle, FileDragRequestId } from '@deepseek-ai/dsh-util-file-drag'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
|
||||
@@ -254,3 +255,27 @@ it('forwards browser guest input only for the focused live webview lease', async
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
off()
|
||||
})
|
||||
|
||||
it('prepares original image bytes and starts native drags without renderer filesystem paths', async () => {
|
||||
vi.stubGlobal('location', new URL('dsh-app://app/'))
|
||||
await import('../src/preload-app.ts')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi
|
||||
const bytes = new Uint8Array([137, 80, 78, 71])
|
||||
electron.ipcRenderer.invoke.mockResolvedValueOnce('image-handle').mockResolvedValueOnce(true).mockResolvedValueOnce(undefined)
|
||||
await expect(api.fileDrag.prepare({ kind: 'bytes', bytes, name: 'original.png', mediaType: 'image/png' }, 'request' as FileDragRequestId)).resolves.toBe('image-handle')
|
||||
await expect(api.fileDrag.start('image-handle' as FileDragHandle)).resolves.toBe(true)
|
||||
api.fileDrag.release('image-handle' as FileDragHandle)
|
||||
api.fileDrag.cancel('request' as FileDragRequestId)
|
||||
expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
|
||||
[DESKTOP_IPC.fileDragPrepare, { kind: 'bytes', bytes, name: 'original.png', mediaType: 'image/png' }, 'request'],
|
||||
[DESKTOP_IPC.fileDragStart, 'image-handle'],
|
||||
[DESKTOP_IPC.fileDragRelease, 'image-handle'],
|
||||
[DESKTOP_IPC.fileDragCancel, 'request'],
|
||||
])
|
||||
})
|
||||
|
||||
// The Host and Client compiler faces share no runtime entry; this assignment checks their image protocol.
|
||||
const imageProtocol = (value: import('../src/ipc.ts').DshDesktopProductApi['fileDrag']): DesktopFileDrag => value
|
||||
const imageProtocolBack = (value: DesktopFileDrag): import('../src/ipc.ts').DshDesktopProductApi['fileDrag'] => value
|
||||
void imageProtocol
|
||||
void imageProtocolBack
|
||||
@@ -43,8 +43,9 @@ it('classifies the current workspace and shares only runtime patch bytes', () =>
|
||||
const before = readFileSync(join(repository, 'pnpm-lock.yaml'))
|
||||
const plan = prepareRuntimePatches(project, repository)
|
||||
expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/[email protected]', '[email protected]'])
|
||||
expect(plan.workspaceOnly).toHaveLength(7)
|
||||
expect(plan.workspaceOnly).toHaveLength(8)
|
||||
expect(plan.workspaceOnly).toContain('@botiverse/oar')
|
||||
expect(plan.workspaceOnly).toContain('lexical')
|
||||
const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record<string, string> }
|
||||
expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined()
|
||||
expect(settings.overrides['@earendil-works/pi-ai@^1.3.0']).toBe('1.3.0')
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{
|
||||
"path": "../../packages/boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/boot/official-bundles"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/home-paths"
|
||||
},
|
||||
@@ -31,6 +34,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/product-analytics/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/file-drag"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
|
||||
/apps-web-browser-e2e:
|
||||
en: 3e8ecd3509fa944f
|
||||
zh: 3fd3ff9fcd1e1e90
|
||||
en: 2deef0df72a6465c
|
||||
zh: b24989ffb2d8586a
|
||||
/apps-web-browser-e2e/completion-observations:
|
||||
en: 1f9c004f3eb465fb
|
||||
zh: 3e45ef8014bad48d
|
||||
en: 8c139966983959cc
|
||||
zh: 54f09f9b7ac84cda
|
||||
/apps-web-browser-e2e/these-are-host-face-tests:
|
||||
en: 101d687b1d98c5b7
|
||||
zh: f8c31115e8a15be7
|
||||
|
||||
@@ -14,6 +14,8 @@ On Linux, `--with-deps` installs dependencies through the system package manager
|
||||
|
||||
Ordinary scenarios begin with no registered Workspace or Session and a durable marker recording a removed default Workspace, so explicit folder-selection scenarios retain control of their cwd. `launchWebScaffold({ firstUse: true })` leaves initialization eligible for startup scenarios.
|
||||
|
||||
Same-day message goldens use the shared `WEB_FIXTURE_TIME`: seeded transcripts pass it as `createdAt` and pin the browser calendar with `pinBrowserClock`; scenarios that submit live messages also use `pinHostClock` and restore it after teardown. These clocks advance with real elapsed time while keeping native timers intact, so midnight does not change a scenario's date labels.
|
||||
|
||||
## Completion observations
|
||||
|
||||
State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Model-picker persistence assertions wait for the saved default, independently of menu closure. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. The [CI test reliability workflow](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state) owns these synchronization rules.
|
||||
@@ -24,6 +26,8 @@ Plugin-manager and HMR lifecycle switches wait up to `PLUGIN_TOGGLE_SETTLE_MS` (
|
||||
|
||||
Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the locator again when its old element detaches and checks connection in the same browser task as native scrolling. Scenarios retain their visibility and geometry assertions after scrolling.
|
||||
|
||||
Question-composer resize assertions wait for overflow, the clipping mask, and toggle visibility to agree after CSS scroll timelines update.
|
||||
|
||||
[The snapshot after closing a sidebar pane](shortcuts-panels.e2e.ts) moves the pointer outside the controls and waits for hover tooltips to close before capture. Keyboard focus remains on the pane.
|
||||
|
||||
[The same-origin terminal case](sidebar-terminal.e2e.ts) waits for the UI-triggered Host close operation before checking process termination. It also checks the first window's process, saved bindings and layout after reload.
|
||||
|
||||
@@ -14,6 +14,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
|
||||
|
||||
普通场景以没有已登记 Workspace 或 Session、但持久化标记记录默认 Workspace 已被删除的状态启动,使显式文件夹选择场景自行决定 cwd。`launchWebScaffold({ firstUse: true })` 保留初始化资格,供启动场景使用。
|
||||
|
||||
当天消息的 golden 使用共享的 `WEB_FIXTURE_TIME`:预置会话将它传为 `createdAt`,并通过 `pinBrowserClock` 固定浏览器日期;提交实时消息的场景还使用 `pinHostClock`,在清理后恢复。固定后的时钟随真实耗时前进,保留原生定时器,因此跨午夜不会改变场景的日期标签。
|
||||
|
||||
## 完成状态观察
|
||||
|
||||
依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。模型选择器的持久化断言等待默认设置保存完成,不以菜单关闭作为完成信号。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。[CI 测试可靠性工作流](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state)负责这些同步规则。
|
||||
@@ -24,6 +26,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
|
||||
|
||||
显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。
|
||||
|
||||
问答输入框的缩放断言等待 CSS 滚动时间线更新,使溢出、裁剪遮罩与展开按钮的可见性一致。
|
||||
|
||||
[侧栏窗格关闭后的快照](shortcuts-panels.e2e.ts)在捕获前将指针移出控件,并等待悬停提示关闭。键盘焦点保持在窗格上。
|
||||
|
||||
[同源窗口终端用例](sidebar-terminal.e2e.ts)等待 UI 触发的 Host 关闭操作完成后,再检查进程终止。它还检查第一个窗口的进程、保存的绑定以及重载后的布局。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Browser selection and focus after presses in the composer's unused space. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Page } from 'playwright'
|
||||
import { chromium, webkit, type Page } from 'playwright'
|
||||
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
|
||||
@@ -39,6 +39,55 @@ function describeSelection(state: Awaited<ReturnType<typeof selectionState>>): s
|
||||
return `${state.focused ? 'focused' : 'unfocused'}; selection ${JSON.stringify(state.text)} (${state.anchor} → ${state.focus})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the visible composer still when keyboard panning changes viewport coordinates.
|
||||
* @param page - Page with the focused composer.
|
||||
* @returns The observed page and composer scroll behavior.
|
||||
*/
|
||||
async function exerciseKeyboardViewport(page: Page): Promise<string> {
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]')
|
||||
const restore = await page.evaluateHandle(() => {
|
||||
const viewport = window.visualViewport
|
||||
if (viewport === null) throw new Error('Expected a visual viewport')
|
||||
const style = document.documentElement.style
|
||||
const height = style.getPropertyValue('height')
|
||||
const priority = style.getPropertyPriority('height')
|
||||
const scroll = window.scrollY
|
||||
const offset = Object.getOwnPropertyDescriptor(viewport, 'offsetTop')
|
||||
style.setProperty('height', `${window.innerHeight * 2}px`)
|
||||
const input = document.querySelector<HTMLElement>('[data-composer-input]')!
|
||||
window.scrollTo(0, input.getBoundingClientRect().top + scroll - window.innerHeight / 4)
|
||||
// Safari can report the keyboard's page displacement as offsetTop even
|
||||
// though DOM rectangles already account for that page scroll.
|
||||
Object.defineProperty(viewport, 'offsetTop', { configurable: true, get: () => window.scrollY })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
return () => {
|
||||
if (offset === undefined) Reflect.deleteProperty(viewport, 'offsetTop')
|
||||
else Object.defineProperty(viewport, 'offsetTop', offset)
|
||||
if (height === '') style.removeProperty('height')
|
||||
else style.setProperty('height', height, priority)
|
||||
window.scrollTo(0, scroll)
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
}
|
||||
})
|
||||
try {
|
||||
const before = await input.evaluate(el => ({ top: el.getBoundingClientRect().top, scroll: window.scrollY }))
|
||||
expect(before.scroll).toBeGreaterThan(0)
|
||||
expect(before.top).toBeGreaterThanOrEqual(0)
|
||||
await page.keyboard.insertText('z')
|
||||
const after = await input.evaluate(el => new Promise<{ top: number; scroll: number }>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
resolve({ top: el.getBoundingClientRect().top, scroll: window.scrollY })
|
||||
}))
|
||||
}))
|
||||
expect(after).toEqual(before)
|
||||
return '- typing with a keyboard-panned viewport preserves page scroll and composer position'
|
||||
} finally {
|
||||
await restore.evaluate((dispose) => { dispose() })
|
||||
await restore.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercise the card's top, inter-row gap, toolbar gap and bottom padding.
|
||||
* @param page - Page containing an editable composer.
|
||||
@@ -113,6 +162,15 @@ async function exerciseComposer(page: Page): Promise<string[]> {
|
||||
await page.keyboard.insertText('!')
|
||||
await expect.poll(() => input.textContent()).toBe('alpha BET!A gamma')
|
||||
observations.push(`- typing replaces the restored selection and resumes at the restored caret: ${await input.textContent()}`)
|
||||
const retained = await input.elementHandle()
|
||||
const beforeResize = await selectionState(page)
|
||||
for (const width of [390, 768, 1680]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
expect(await retained!.evaluate(element => element.isConnected)).toBe(true)
|
||||
expect(await selectionState(page)).toEqual(beforeResize)
|
||||
}
|
||||
observations.push('- resizing through 390px, 768px and 1680px preserves the editor, focus, draft and caret')
|
||||
observations.push(await exerciseKeyboardViewport(page))
|
||||
return observations
|
||||
}
|
||||
|
||||
@@ -145,7 +203,87 @@ it('restores the previous caret or selection in fresh and existing sessions', as
|
||||
'# Composer whitespace focus', '', '## Fresh session', '', ...hero,
|
||||
'', '## Existing session', '', ...docked,
|
||||
].join('\n'), webSnapshotMode())
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md', 'submission.expected.md'])
|
||||
if (webSnapshotMode() !== 'record') expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 180_000)
|
||||
|
||||
it('keeps mobile submission keyboard navigation in the composer while retaining desktop focus', async () => {
|
||||
const observations: string[] = []
|
||||
for (const { engine, mobile, submission } of [
|
||||
{ engine: chromium, mobile: false, submission: 'Send' },
|
||||
{ engine: chromium, mobile: true, submission: 'Send' },
|
||||
{ engine: chromium, mobile: true, submission: 'Enter' },
|
||||
{ engine: webkit, mobile: true, submission: 'Send' },
|
||||
{ engine: webkit, mobile: true, submission: 'Enter' },
|
||||
]) {
|
||||
const scaffold = await launchWebScaffold({
|
||||
replayFixture: fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)),
|
||||
compareReplaySession: false,
|
||||
})
|
||||
try {
|
||||
const browser = await engine.launch()
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: mobile ? 390 : 1280, height: 844 },
|
||||
locale: 'en-US', timezoneId: 'Asia/Shanghai',
|
||||
...mobile ? {
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true, hasTouch: true,
|
||||
} : {},
|
||||
})
|
||||
const tripwire = watchConsole(page)
|
||||
const platform = `${engine.name()} ${mobile ? 'mobile' : 'desktop'} ${submission}`
|
||||
let finished = false
|
||||
const off = scaffold.ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'turn/end') finished = true
|
||||
})
|
||||
try {
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, `submission-${platform}`)
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]')
|
||||
await input.fill('Reply with a one-sentence description of event sourcing, then stop.')
|
||||
expect(await input.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
if (submission === 'Enter') await page.keyboard.press('Enter')
|
||||
else {
|
||||
const send = page.getByRole('button', { name: 'Send message', exact: true })
|
||||
if (mobile) await send.tap()
|
||||
else await send.click()
|
||||
}
|
||||
await expect.poll(() => input.textContent()).toBe('')
|
||||
await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(!mobile)
|
||||
await expect.poll(() => finished, { timeout: 60_000 }).toBe(true)
|
||||
expect(await input.evaluate(element => document.activeElement === element)).toBe(!mobile)
|
||||
if (mobile) {
|
||||
const card = page.locator('[data-composer-card]')
|
||||
expect(await card.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
// Option+Tab includes buttons in WebKit's keyboard navigation mode.
|
||||
const next = engine === webkit ? 'Alt+Tab' : 'Tab'
|
||||
await page.keyboard.press(next)
|
||||
expect(await input.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await page.keyboard.press(next)
|
||||
const attachment = page.getByRole('button', { name: 'Add files or run commands', exact: true })
|
||||
expect(await attachment.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
observations.push(`- ${platform}: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button`)
|
||||
} else {
|
||||
observations.push(`- ${platform}: clears the draft; editor stays focused through response completion`)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
if (webSnapshotMode() !== 'record') expect(tripwire.warnings).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, `web-e2e-composer-submission-${platform}`)
|
||||
throw error
|
||||
} finally {
|
||||
off()
|
||||
await page.close()
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
} finally {
|
||||
await scaffold.close()
|
||||
}
|
||||
}
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'submission.expected.md'), observations.join('\n'), webSnapshotMode())
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md', 'submission.expected.md'])
|
||||
}, 180_000)
|
||||
@@ -75,7 +75,10 @@ describe('creation recommendation after an explicit wish', () => {
|
||||
const compactHeight = await plan.evaluate(element => element.clientHeight)
|
||||
const previewText = await plan.innerText()
|
||||
expect(previewText.endsWith('…')).toBe(true)
|
||||
expect(Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(previewText))).toHaveLength(81)
|
||||
// The preview keeps about 260 Latin-letter widths before the ellipsis; a trailing space may be trimmed.
|
||||
const previewLength = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(previewText)).length
|
||||
expect(previewLength).toBeGreaterThan(200)
|
||||
expect(previewLength).toBeLessThanOrEqual(261)
|
||||
await expand.click()
|
||||
expect(await plan.evaluate(element => element.clientHeight)).toBeGreaterThan(compactHeight)
|
||||
expect(await card.getByRole('button', { name: 'Create', exact: true }).isEnabled()).toBe(true)
|
||||
|
||||
@@ -53,7 +53,7 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
|
||||
expect(await card.getByText('实验性', { exact: true }).count()).toBe(1)
|
||||
const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await card.textContent()).toContain('需切换到 PTC 模式')
|
||||
expect(await card.textContent()).toContain('请切换到 PTC 模式')
|
||||
const enabling = Promise.withResolvers<undefined>()
|
||||
const setEnabled = scaffold.ctx.pluginManager.setBundleEnabled.bind(scaffold.ctx.pluginManager)
|
||||
let applying: ReturnType<typeof setEnabled> | undefined
|
||||
|
||||
@@ -199,6 +199,99 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ device: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', automatic: false },
|
||||
{ device: 'Android Chrome', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', automatic: false },
|
||||
{ device: 'iPhone Chrome', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 CriOS/139.0.0.0 Mobile/15E148 Safari/604.1', automatic: false },
|
||||
{ device: 'narrow desktop', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', automatic: true },
|
||||
])('respects $device composer sizing and search focus when opening /model', async ({ userAgent, automatic }) => {
|
||||
const context = await browser.newContext({
|
||||
userAgent, viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: !automatic,
|
||||
})
|
||||
try {
|
||||
const devicePage = await context.newPage()
|
||||
const consoleWatch = watchConsole(devicePage)
|
||||
await devicePage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await devicePage.getByRole('textbox', { name: '选择工作区' }).click()
|
||||
const composer = devicePage.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
await composer.waitFor()
|
||||
expect(await composer.evaluate(element => getComputedStyle(element).fontSize)).toBe(automatic ? '14px' : '16px')
|
||||
await composer.fill('/model')
|
||||
await devicePage.getByRole('option', { name: /^模型/ }).click()
|
||||
const search = devicePage.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await search.waitFor()
|
||||
await devicePage.getByRole('option', { name: /Origin Large/ }).waitFor()
|
||||
expect(await search.evaluate(input => document.activeElement === input)).toBe(automatic)
|
||||
await search.click()
|
||||
expect(await search.evaluate(input => document.activeElement === input)).toBe(true)
|
||||
await search.fill('Origin')
|
||||
await devicePage.getByRole('option', { name: /Origin Large/ }).waitFor()
|
||||
expect(await devicePage.getByRole('option', { name: /Acme/ }).count()).toBe(0)
|
||||
await search.press('Escape')
|
||||
await composer.fill('')
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ device: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone' },
|
||||
{ device: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel' },
|
||||
])('retains model menu keyboard focus on $device', async ({ userAgent, platform }) => {
|
||||
const context = await browser.newContext({
|
||||
userAgent, viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: true,
|
||||
})
|
||||
try {
|
||||
await context.addInitScript(({ platform }) => {
|
||||
Object.defineProperty(navigator, 'platform', { configurable: true, value: platform })
|
||||
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
|
||||
}, { platform })
|
||||
const devicePage = await context.newPage()
|
||||
const consoleWatch = watchConsole(devicePage)
|
||||
await devicePage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await devicePage.getByRole('textbox', { name: '选择工作区' }).click()
|
||||
const trigger = devicePage.getByRole('button', { name: /^选择模型/ })
|
||||
const observations: string[] = []
|
||||
const observeFocus = async (action: string): Promise<void> => {
|
||||
const focused = await devicePage.evaluate(() => {
|
||||
const element = document.activeElement!
|
||||
const role = element.getAttribute('role') ?? element.tagName.toLowerCase()
|
||||
const label = element.getAttribute('aria-label') ?? element.textContent?.replace(/\s+/g, ' ').trim()
|
||||
return `${role}: ${label}`
|
||||
})
|
||||
observations.push(`${action}: ${focused}`)
|
||||
}
|
||||
await trigger.click()
|
||||
await expect.poll(() => trigger.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Open')
|
||||
await devicePage.keyboard.press('ArrowDown')
|
||||
await devicePage.keyboard.press('Enter')
|
||||
const model = devicePage.getByRole('menuitemradio', { name: 'Origin Large', exact: true })
|
||||
await expect.poll(() => model.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Enter model pane')
|
||||
await devicePage.keyboard.press('ArrowDown')
|
||||
const search = devicePage.getByRole('searchbox', { name: '搜索模型…' })
|
||||
await expect.poll(() => search.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Navigate models')
|
||||
await devicePage.keyboard.press('Escape')
|
||||
const modelCell = devicePage.getByRole('menuitem', { name: /^模型/ })
|
||||
await expect.poll(() => modelCell.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Return to root')
|
||||
await devicePage.keyboard.press('Escape')
|
||||
await expect.poll(() => trigger.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Close')
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/default-model/mobile-keyboard.expected.md', import.meta.url)),
|
||||
observations.join('\n'), webSnapshotMode(),
|
||||
)
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('shares provider order, fuzzy result order and sticky header material between both model pickers', async () => {
|
||||
const readGroups = (surface: Locator, role: 'option' | 'menuitemradio') => surface.locator('[data-menu-group]')
|
||||
.evaluateAll((groups, rowRole) => groups.map(group => ({
|
||||
|
||||
@@ -1082,7 +1082,7 @@ else process.exit(1);
|
||||
width: (node as HTMLImageElement).naturalWidth,
|
||||
height: (node as HTMLImageElement).naturalHeight,
|
||||
draggable: (node as HTMLImageElement).draggable,
|
||||
}))).toEqual({ width: 1, height: 1, draggable: false })
|
||||
}))).toEqual({ width: 1, height: 1, draggable: true })
|
||||
const centering = await tinyImage.evaluate((node) => {
|
||||
const image = node.getBoundingClientRect()
|
||||
const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect()
|
||||
@@ -1105,6 +1105,47 @@ else process.exit(1);
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(() => imageZoom.innerText()).toBe('100%')
|
||||
|
||||
await tinyImage.dragTo(input)
|
||||
const draggedPreview = page.getByRole('group', { name: 'Pending attachments', exact: true }).getByRole('img')
|
||||
await draggedPreview.waitFor({ state: 'visible', timeout: 5000 })
|
||||
const draggedBytes = await draggedPreview.evaluate(async (node: HTMLImageElement) =>
|
||||
Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer())))
|
||||
expect(Buffer.from(draggedBytes)).toEqual(TINY_PNG)
|
||||
expect(await input.innerText()).toBe('')
|
||||
await page.getByRole('button', { name: 'Remove image tiny.png', exact: true }).click()
|
||||
const endedDrag = await tinyImage.evaluateHandle((node) => {
|
||||
const proof = { token: '', started: false, ended: false }
|
||||
window.addEventListener('dragstart', (event) => {
|
||||
proof.token = event.dataTransfer!.getData('application/x-dsh-file')
|
||||
proof.started = event.isTrusted && proof.token !== ''
|
||||
}, { once: true })
|
||||
node.addEventListener('dragend', (event) => { proof.ended = event.isTrusted && !node.isConnected }, { once: true })
|
||||
return proof
|
||||
})
|
||||
const tinyRect = (await tinyImage.boundingBox())!
|
||||
const inputRect = (await input.boundingBox())!
|
||||
await page.mouse.move(tinyRect.x + tinyRect.width / 2, tinyRect.y + tinyRect.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tinyRect.x - 20, tinyRect.y, { steps: 4 })
|
||||
await expect.poll(() => endedDrag.evaluate(proof => proof.started)).toBe(true)
|
||||
const tinyTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('tiny.png', { exact: true }) })
|
||||
await tinyTab.locator('[data-dockkit-tab-close]').evaluate((button: HTMLElement) => { button.click() })
|
||||
await tinyImage.waitFor({ state: 'detached' })
|
||||
await page.mouse.move(inputRect.x + inputRect.width / 2, inputRect.y + inputRect.height / 2, { steps: 5 })
|
||||
await page.mouse.up()
|
||||
await draggedPreview.waitFor({ state: 'visible', timeout: 5000 })
|
||||
expect(Buffer.from(await draggedPreview.evaluate(async (node: HTMLImageElement) =>
|
||||
Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer()))))).toEqual(TINY_PNG)
|
||||
await expect.poll(() => endedDrag.evaluate(proof => proof.ended)).toBe(true)
|
||||
await page.getByRole('button', { name: 'Remove image tiny.png', exact: true }).click()
|
||||
const retiredToken = await endedDrag.evaluate(proof => proof.token)
|
||||
await input.evaluate((node, token) => {
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('application/x-dsh-file', token)
|
||||
node.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer }))
|
||||
}, retiredToken)
|
||||
expect(await draggedPreview.count()).toBe(0)
|
||||
await endedDrag.dispose()
|
||||
await openFile('large.svg')
|
||||
await expect.poll(() => viewer.innerText()).toBe('Image')
|
||||
await viewer.click()
|
||||
@@ -1143,6 +1184,17 @@ else process.exit(1);
|
||||
await page.getByRole('menuitem', { name: '100%', exact: true }).click()
|
||||
await expect.poll(async () => (await largeImage.boundingBox())!.width).toBeCloseTo(1200, 0)
|
||||
expect(await imageScrollport.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
|
||||
await imageScrollport.evaluate((node) => { node.scrollLeft = 0; node.scrollTop = 0 })
|
||||
const panBox = await imageScrollport.boundingBox()
|
||||
if (panBox === null) throw new Error('image viewport missing')
|
||||
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab')
|
||||
await page.mouse.move(panBox.x + 180, panBox.y + 180)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(panBox.x + 100, panBox.y + 100, { steps: 8 })
|
||||
await expect.poll(() => imageScrollport.evaluate(node => [node.scrollLeft, node.scrollTop])).toEqual([80, 80])
|
||||
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grabbing')
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab')
|
||||
svgZoom = await revealDocumentZoom(page, preview)
|
||||
await svgZoom.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
@@ -1151,9 +1203,11 @@ else process.exit(1);
|
||||
expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
|
||||
sections.push([
|
||||
'## Image zoom', '',
|
||||
'- Contained sidebar PNG drags into the composer as an original-byte image attachment',
|
||||
'- Small PNG fit width remains at intrinsic size; 200% doubles it',
|
||||
'- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false',
|
||||
'- Image and Blob identities remain stable while zoom changes',
|
||||
'- Overflowing image drag pans both axes by 80px; releasing restores the grab cursor',
|
||||
].join('\n'))
|
||||
|
||||
const releaseRead = Promise.withResolvers<undefined>()
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- bottom padding: focused; selection "beta" (10 → 6)
|
||||
- return from outside: focused; selection "beta" (10 → 6)
|
||||
- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma
|
||||
- resizing through 390px, 768px and 1680px preserves the editor, focus, draft and caret
|
||||
- typing with a keyboard-panned viewport preserves page scroll and composer position
|
||||
|
||||
## Existing session
|
||||
|
||||
@@ -17,3 +19,5 @@
|
||||
- bottom padding: focused; selection "beta" (10 → 6)
|
||||
- return from outside: focused; selection "beta" (10 → 6)
|
||||
- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma
|
||||
- resizing through 390px, 768px and 1680px preserves the editor, focus, draft and caret
|
||||
- typing with a keyboard-panned viewport preserves page scroll and composer position
|
||||
@@ -0,0 +1,5 @@
|
||||
- chromium desktop Send: clears the draft; editor stays focused through response completion
|
||||
- chromium mobile Send: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
|
||||
- chromium mobile Enter: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
|
||||
- webkit mobile Send: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
|
||||
- webkit mobile Enter: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
|
||||
@@ -0,0 +1,5 @@
|
||||
Open: button: 选择模型,当前 Origin Large
|
||||
Enter model pane: menuitemradio: Origin Large
|
||||
Navigate models: searchbox: 搜索模型…
|
||||
Return to root: menuitem: 模型Origin Large
|
||||
Close: button: 选择模型,当前 Origin Large
|
||||
Loaded 100 of 557 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user