Merge remote-tracking branch 'origin/master' into turtle/async-notice-efficiency

# Conflicts:
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
This commit is contained in:
turtle1999 committed 2026-10-22 14:12:42 +08:00
commit 6f4ce88207
681 files changed
+13063 -6160

No files matched your search

@@ -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
@@ -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.
@@ -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 文件就是镜像里的那些。
@@ -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>` 渲染与内联渲染的结果逐像素一致。
- 文件类型、插件、站点与品牌图形仍以内联方式渲染,暂不可替换。
@@ -15,8 +15,8 @@
en: da694641e1d009de
zh: 9d7492d39039af66
/agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-registry-owns-the-mode:
en: 091b7bc49b027353
zh: 42dc77a810202694
en: 4cd76a34f4712e1a
zh: 303ef72a18644d7c
/agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-run-code-tool-and-the-dispatch-bridge:
en: 6a0e2698b082978e
zh: fbb03503520b30e9
@@ -40,7 +40,7 @@ The [Node execution decision](../architecture/2026-09-11-sandboxed-node-ptc-runt
**SDK prompt section.** In `'ptc'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) added Python and the `ctx.ptcRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output.
**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition.
**Assembly ownership.** `tools:sdk` enters the trusted `system-prompt/assemble` presentation waterfall as a normal section, while `run_code` stays a provider-owned declaration that the waterfall cannot remove or replace. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace the section. Whoever changes it owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition.
**Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution.
@@ -40,7 +40,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
**SDK 提示词段。** 在 `'ptc'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) 加入了 Python 与按 `ctx.ptcRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
**组装所有权。** `tools:sdk` 作为正常段进入受信任的 `system-prompt/assemble` 展示 waterfall,而 `run_code` 仍是提供方持有的声明,waterfall 不能移除或替换它。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换该段。修改它的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
**代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。
@@ -12,8 +12,8 @@
en: 532bb7268d9b93ce
zh: ad47162233222ab0
/agent-note-parallel-tool-call-execution-by-per-call-safety/scheduling-and-ordering:
en: 4e85280cc5eb8697
zh: 8e6b09b1527784fa
en: 47010d626ab15117
zh: a5b77385d7a3e781
/agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract:
en: 16c4ee08b65ead23
zh: df80d6e49982615b
@@ -21,11 +21,11 @@
en: e112957ed8a48039
zh: 37d63f9ea4968fb2
/agent-note-parallel-tool-call-execution-by-per-call-safety/verification:
en: eb5a6aa84980f0ab
zh: eed2680a160e8ed2
en: 224b6838130c113f
zh: f3d7eb68ab5932fb
/agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered:
en: 17becc038bb7df7c
zh: 673d9a9bdfa39edf
/agent-note-parallel-tool-call-execution-by-per-call-safety/consequences:
en: a5508057bc03f4f6
zh: 96b46c59402474c3
en: 20a3e29931e01aa0
zh: acbfc61dd710d960
@@ -26,7 +26,7 @@ A tagged mode, rather than a public boolean scheduler API, keeps resource-aware
## Scheduling and ordering
The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. If a registry mutation makes that call exclusive, the current pool drains before the call starts as the next barrier.
The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. Calls resolve through the step's admitted tool set, so replacing or removing a call's registration makes that unstarted call fail as `UNKNOWN_TOOL`; the scheduler classifies it as exclusive, and the current pool drains before it settles as the next barrier.
For example:
@@ -66,7 +66,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati
## Verification
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration.
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, rejection of unstarted calls after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration.
Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. PTC mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior.
@@ -102,6 +102,6 @@ Ordered commits may hold a fast result behind a slow earlier sibling. This prese
Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step.
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. A call runs only the registration its step declared ([conditional tools](../../../../packages/core/tools/README.md)), so a hot reload, MCP reconnect, or skill reload that re-registers a tool mid-step fails that step's unstarted calls of it; the next step declares and runs the new registration. Already-started calls retain the scheduling decision under which they entered the pool.
A terminal scheduler failure reaches step recovery only after live dispatches settle. Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure while keeping later model history usable.
@@ -26,7 +26,7 @@ Status: implemented
## 调度与顺序
循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。如果注册表变更使该调用变为独占,当前池会先完全排空,然后该调用才作为下一个屏障启动。
循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。调用通过本步骤的准入工具集合解析,因此替换或移除某个调用的注册后,该尚未开始的调用会以 `UNKNOWN_TOOL` 失败;调度器把它分类为独占,当前池会先完全排空,然后它才作为下一个屏障结束。
例如:
@@ -66,7 +66,7 @@ Status: implemented
## 验证
单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后的运行时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。
单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后拒绝尚未开始的调用、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。
快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。PTC mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。
@@ -102,6 +102,6 @@ Status: implemented
并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。调用只执行其步骤所声明的注册([条件工具](../../../../packages/core/tools/README.zh.md)),因此在步骤中途重新注册工具的热重载、MCP 重连或 skill 重载会让该步骤里这个工具尚未开始的调用失败;下一个步骤声明并执行新的注册。已启动的调用保留它们进入池时所依据的调度决策。
终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。
@@ -9,8 +9,8 @@
en: e7c3b5d7702714fe
zh: 268f162b553b37d1
/agent-note-generic-file-upload/decision:
en: 31c7ad96c6ab82c8
zh: 806922fc8fcd1242
en: 1769950d73d30dca
zh: bf296b920ea9b997
/agent-note-generic-file-upload/alternatives-considered:
en: 23f386b05e4e79ef
zh: 0756c4fa30e8d0eb
@@ -21,5 +21,5 @@
en: 3a50ac370d51cda9
zh: d490f58922f8795b
/agent-note-generic-file-upload/related:
en: 7579c49eefdb8488
zh: 68ad384fd9d2f8f9
en: 14e8659b89dc08a8
zh: dec5baa11f168e91
@@ -14,7 +14,7 @@ The command plane had already established a whole-envelope rule for images: a co
Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects/<digest-prefix>/<digest>`; each read-only model-facing path at `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts the owning Session identity together with a `Blob`, exact bytes, or a one-shot `ReadableStream<Uint8Array>`; it uses that identity to assemble the raw route request or invoke the generated Remote directly. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Encoded admission and attachment-error recognition go through `ctx.attachments`, whose provider owns byte storage. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand.
Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session.
Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session.
## Alternatives considered
@@ -54,3 +54,5 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat
The [Trajectory reference](../../../../packages/client/ui-trajectory/README.md) documents ledger counts and inspector attachment layouts.
The [input echo admission reference](../../../../packages/api/session-controller/README.md) owns placement-specific retirement and delayed Inbox suppression for submissions with or without attachments.
The [composer image upload on add](2026-10-21-composer-image-upload-on-add.md) note extends this background-upload queue to the images of an ordinary Session's draft.
@@ -14,7 +14,7 @@ Status: implemented
文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects/<digest-prefix>/<digest>` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream<Uint8Array>`;它使用该标识组装原始路由请求,或直接调用生成的 Remote。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。编码准入和附件错误识别通过 `ctx.attachments` 完成,字节存储由附件提供方拥有。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。
默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。
默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每项上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。
## 考虑过的替代方案
@@ -54,3 +54,5 @@ Status: implemented
[Trajectory 参考](../../../../packages/client/ui-trajectory/README.zh.md)说明列表计数与详情面板附件布局。
[输入回显入档参考](../../../../packages/api/session-controller/README.zh.md)负责有无附件提交的按展示归属回收和迟到 Inbox 排除。
[输入框在加入时上传图片](2026-10-21-composer-image-upload-on-add.zh.md) 记录把这条后台上传队列扩展到普通 Session 草稿里的图片。
@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-10-21-composer-image-upload-on-add.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md
/agent-note-composer-image-upload-on-add:
en: 996b39b53086b9ac
zh: c81fade50ac895ec
/agent-note-composer-image-upload-on-add/problem:
en: b184ade2bd353f50
zh: 00b97c91df54b310
/agent-note-composer-image-upload-on-add/decision:
en: eada67e3c3d50f47
zh: 5fb4f3a5e774e47e
/agent-note-composer-image-upload-on-add/alternatives-considered:
en: afa82b9bce9015f6
zh: d088b92c2cb6c622
/agent-note-composer-image-upload-on-add/consequences:
en: 08d1bcb970beb40e
zh: f42db4052618c289
/agent-note-composer-image-upload-on-add/testing:
en: f2d2c59272ffcf70
zh: 46c933fd2aeb87ea
/agent-note-composer-image-upload-on-add/related:
en: 6eac98f60c3b99f9
zh: c1e318e74f44fbb4
@@ -0,0 +1,36 @@
# Agent Note: Composer image upload on add
Status: implemented
English | [中文](2026-10-21-composer-image-upload-on-add.zh.md)
## Problem
The Web composer kept image drafts in the browser and base64-encoded each image into `session/prompt` at send time, so the Host received, validated, normalized, and stored image bytes inside prompt admission. Generic files already uploaded when picked ([generic file upload](2026-08-26-generic-file-upload.md)). An image message therefore carried up to the per-message image byte limit in one RPC body, and its send time grew with image size; a composer that waits for the Host to accept a message before clearing it would hold the draft for that whole transfer.
## Decision
An ordinary Session's composer uploads each image draft as soon as it is added, through the same FIFO background-upload queue, Worker carriers, progress, retry, and cancellation that generic files use. The raw upload route takes a `mediaType` query parameter for an image upload; the Host reads at most the smaller of the deployment's per-image and per-message image byte limits, then validates, normalizes, and stores the image with `ctx.attachments.saveImages` exactly as prompt admission would, and stages the normalized image under a receipt for that Session. The prompt cites the receipt in an ordinary file part, so `session/prompt` keeps its wire types; prompt admission resolves the receipt to the stored image, applies the model's image-input check to the prompt, and counts stored images toward `maxImagesPerMessage` and `maxMessageImageBytes` together with encoded ones, each stored image by the bytes of its upload. Send waits until every image draft has a receipt, and a failed image upload turns its thumbnail into a retry control. Commands accept only file receipts, so command submissions keep encoding images inline. A continuable subagent's composer keeps inline images because the Host stages uploads only for ordinary Sessions; SDK and other wire callers may still send encoded images.
## Alternatives considered
- **A dedicated image-receipt prompt part.** Rejected: the receipt already names what was staged, so a second part kind would widen the `session/prompt` wire union, its generated catalogs, and every consumer that switches on parts without changing what the Host admits.
- **A separate image upload route or Remote method.** Rejected: one route with an explicit `mediaType` parameter reuses Session addressing, carrier selection, progress, and the failure envelope; the exact-bytes Remote fallback has no image caller.
- **Upload images but normalize them at send.** Rejected: normalization is the expensive part of image admission, so deferring it would keep the send-time cost this change removes.
## Consequences
- Sending an image message transfers only references, and the normalization cost moves to the moment the image is added.
- Per-image refusals such as an undecodable or oversized image surface when the image is added: the thumbnail names the refusal instead of offering a retry, and Enter repeats it. The model's image-input check and the per-message image count and byte limits still apply at send.
- Commands accept only file receipts, so a command submission that carries an image waits for the image upload like a message and then encodes the image inline: the image is stored a second time, and its unused receipt lasts until its Session ends.
- Removing an image while it uploads cancels the upload, and a cancelled upload receives no receipt; an image whose storage had already begun, or whose upload had finished, stays under `DSH_HOME`, which never deletes attachments. On a remote Host the bytes reach the server as soon as the user adds the image.
- Staged image receipts share the generic files' in-memory lifetime: a Host restart between adding and sending surfaces `FILE_NOT_STAGED`, and the user adds the image again.
- Image drafts, like file drafts, are not restored after a page reload or after their Session's client scope is released.
## Testing
Host coverage pins image staging through the raw route, the per-image byte bound that stops reading, unsupported types, subagent refusal, command refusal of image receipts, receipt resolution into the admitted image, the image-input check for an uploaded image, the per-message byte limit across receipts, and withheld receipts for uploads cancelled after their last chunk or during storage. Attachment admission coverage pins stored-image pass-through and the combined image count and byte limits. Client coverage pins image upload start, retry, cancellation, the send gate, receipt citation without re-encoding, inline images for drafts without an upload, the rail's progress, retry, and refusal states, and the Enter explanation of a held send.
## Related
The [generic file upload](2026-08-26-generic-file-upload.md) note owns the background-upload queue, carriers, staging lifetime, and file storage that image uploads reuse.
@@ -0,0 +1,36 @@
# Agent Note: 输入框在加入时上传图片
Status: implemented
[English](2026-10-21-composer-image-upload-on-add.md) | 中文
## 问题
Web 输入框把图片草稿留在浏览器里,发送时才把每张图片 base64 编码进 `session/prompt`,Host 在 prompt 准入时才接收、校验、规整并存储图片字节。通用文件早已在选中时上传([通用文件上传](2026-08-26-generic-file-upload.zh.md))。因此一条图片消息要在一个 RPC 请求体里携带最多达到单条消息图片字节上限的数据,发送耗时随图片大小增长;如果输入框要等 Host 接纳消息后才清空,草稿就要在整个传输期间一直保留。
## 决定
普通 Session 的输入框在图片草稿加入时就上传它,复用通用文件的先进先出后台上传队列、Worker 载体、进度、重试与取消。原始上传路由用 `mediaType` 查询参数表示图片上传;Host 最多读取部署配置的单张图片与单条消息图片字节上限中较小的一个,然后按 prompt 准入的同样方式用 `ctx.attachments.saveImages` 校验、规整并存储图片,再以该 Session 的凭证暂存规整后的图片。prompt 在普通文件部分引用该凭证,因此 `session/prompt` 的线上类型不变;prompt 准入把凭证解析为已存储的图片,对这条 prompt 执行模型的图片输入检查,并把已存储图片与编码图片一起计入 `maxImagesPerMessage` 和 `maxMessageImageBytes`,已存储图片按其上传的字节数计入。发送会等到每个图片草稿都取得凭证;图片上传失败时,缩略图变成重试控件。命令只接受文件凭证,所以命令提交仍以内联方式编码图片。continuable 子代理的输入框继续内联图片,因为 Host 只为普通 Session 暂存上传;SDK 和其他线上调用方仍可发送编码图片。
## 考虑过的替代方案
- **专用的图片凭证 prompt 部分。** 已否决:凭证本身已经指明暂存的内容,新增一种部分类型会扩大 `session/prompt` 线上联合类型、它的生成目录以及每个按部分类型分支的消费方,却不改变 Host 准入的内容。
- **单独的图片上传路由或 Remote 方法。** 已否决:一条带显式 `mediaType` 参数的路由复用了 Session 寻址、载体选择、进度和失败信封;精确字节的 Remote 兜底没有图片调用方。
- **加入时上传、发送时再规整。** 已否决:规整是图片准入中开销最大的部分,推迟它会保留这次改动要消除的发送耗时。
## 后果
- 发送图片消息只传引用,规整开销移到图片加入的时刻。
- 无法解码、超过单张上限等逐张拒绝在图片加入时出现:缩略图说明拒绝原因而不提供重试,按 Enter 也会提示同样的原因。模型的图片输入检查以及单条消息的图片数量与总字节上限仍在发送时执行。
- 命令只接受文件凭证,所以带图片的命令提交也要像消息一样等图片上传完成,再以内联方式编码图片:同一张图会再存储一次,未被引用的凭证一直留到 Session 结束。
- 图片上传中移除会取消上传,取消的上传不会取得凭证;已开始存储或已上传完成的图片仍留在 `DSH_HOME` 下,附件存储从不删除。Host 在远端时,用户一加入图片,字节就到达服务器。
- 暂存的图片凭证与通用文件同样只存在内存里:在加入与发送之间重启 Host 会得到 `FILE_NOT_STAGED`,用户需要重新加入图片。
- 与文件草稿一样,图片草稿在页面刷新后或其 Session 的客户端作用域释放后不会恢复。
## 测试
Host 侧覆盖经原始路由暂存图片、单张字节上限一超出即停止读取、不支持的类型、子代理拒绝、命令拒绝图片凭证、凭证解析为准入后的图片、已上传图片的图片输入检查、多个凭证合计的单条消息字节上限,以及在最后一个分块之后或存储期间取消的上传不签发凭证。附件准入覆盖已存储图片原样通过,以及合并后的图片数量与总字节上限。客户端覆盖图片上传的开始、重试、取消、发送闸门、发送时引用凭证而不重新编码、没有上传的草稿以内联方式发送图片,附件栏的进度、重试与拒绝状态,以及发送被挡住时按 Enter 的说明。
## 相关记录
[通用文件上传](2026-08-26-generic-file-upload.zh.md) 记录负责图片上传所复用的后台上传队列、载体、暂存生命周期与文件存储。
@@ -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.
@@ -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 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。
@@ -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 保留自己的工作区构建和完整检查。
@@ -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 的峰值。
@@ -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
@@ -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 -1
View File
@@ -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.
+87
View File
@@ -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/checkout@v7.0.1
- uses: actions/setup-python@v6.3.0
with:
python-version: '3.10'
cache: pip
- name: Install uv
run: python -m pip install uv==0.11.23
- uses: actions/setup-node@v7.0.0
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, ', ') }})"
+255
View File
@@ -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/checkout@v7.0.1
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/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
- uses: actions/setup-node@v7.0.0
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/checkout@v7.0.1
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/restore@v6.1.0
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/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
- uses: actions/setup-node@v7.0.0
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/save@v6.1.0
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/checkout@v7.0.1
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/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
- uses: actions/setup-node@v7.0.0
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
View File
@@ -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/checkout@v7.0.1
- uses: actions/setup-python@v6.3.0
with:
python-version: '3.10'
cache: pip
- name: Install uv
run: python -m pip install uv==0.11.23
- uses: actions/setup-node@v7.0.0
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/checkout@v7.0.1
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/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
- uses: actions/setup-node@v7.0.0
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/checkout@v7.0.1
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/restore@v6.1.0
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/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
- uses: actions/setup-node@v7.0.0
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/save@v6.1.0
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/checkout@v7.0.1
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/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
- uses: actions/setup-node@v7.0.0
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
+1
View File
@@ -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 |
+1
View File
@@ -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:*",
+2 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+2 -1
View File
@@ -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')
+2 -1
View File
@@ -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'
+2 -1
View File
@@ -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'
@@ -17,8 +17,8 @@
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}}
{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"exit_plan_mode"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}}
{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
@@ -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')
+3
View File
@@ -20,6 +20,9 @@
{
"path": "../../packages/boot/app-boot"
},
{
"path": "../../packages/boot/official-bundles"
},
{
"path": "../../packages/boot/cmdline"
},
+7
View File
@@ -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:*"
}
}
+64
View File
@@ -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') }
}
+30
View File
@@ -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
+82
View File
@@ -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()
})
+18
View File
@@ -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"
}
]
}
+2 -2
View File
@@ -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
+2
View File
@@ -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.
+2
View File
@@ -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 校验。
+3 -1
View File
@@ -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:*"
}
}
+121
View File
@@ -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
}
}
+167
View File
@@ -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()
}
}
+42 -6
View File
@@ -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)
}
}
+6
View File
@@ -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
/**
+30 -2
View File
@@ -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) => {
+12
View File
@@ -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>,
+2 -3
View File
@@ -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()
})
+165
View File
@@ -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')
})
+41
View File
@@ -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')
+45
View File
@@ -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)
+25
View File
@@ -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
+6
View File
@@ -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": [
+4 -4
View File
@@ -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
+4
View File
@@ -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.
+4
View File
@@ -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 关闭操作完成后,再检查进程终止。它还检查第一个窗口的进程、保存的绑定以及重载后的布局。
+3
View File
@@ -179,6 +179,7 @@ interface FixtureWindow extends Window {
__DSH_BOOT__?: WebBootGraph
__ModuleLoader__?: ClientModuleLoaderTarget
__DSH_TRANSPORT__?: { readonly rpc: RemoteMock['rpc'] }
__DSH_FILE_UPLOAD__?: { readonly fetch: AssembledRemote['fileUpload'] }
}
class ResizeObserverStub {
@@ -253,6 +254,7 @@ export function installAssembledBootEnv(): void {
delete win.__DSH_BOOT__
delete win.__ModuleLoader__
delete win.__DSH_TRANSPORT__
delete win.__DSH_FILE_UPLOAD__
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
document.title = ''
@@ -281,6 +283,7 @@ export function mountAssembledApp(options: AssembledBootOptions = {}): Assembled
const remote = createAssembledRemote(options.remote)
mountedRemote = remote.mock
win.__DSH_TRANSPORT__ = { rpc: remote.mock.rpc }
win.__DSH_FILE_UPLOAD__ = { fetch: remote.fileUpload }
history.replaceState(null, '', '/')
const root = document.createElement('div')
root.id = 'root'
+30 -1
View File
@@ -5,6 +5,7 @@
* Fixture Sessions have no retained Host terminals to restore.
*/
import { Buffer } from 'node:buffer'
import { readFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
@@ -106,6 +107,8 @@ export interface AssembledRemoteOptions {
export interface AssembledRemote {
readonly mock: RemoteMock
/** Page-owned upload carrier: stages an image upload as the Host would and answers its receipt. */
readonly fileUpload: (input: string | URL, init: RequestInit) => Promise<Response>
}
const fixtureSource = readFileSync(
@@ -132,6 +135,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
return [sessionId, next] as const
}))
const attachments = new Map<string, unknown>([['fixture:image', structuredClone(fixture.attachment)]])
const stagedImages = new Map<string, Record<string, unknown>>()
const blankSessionProjections = fixture.control.value.projections['fx-gamma']
if (blankSessionProjections === undefined) {
throw new Error('assembled fixture: blank Session projections missing')
@@ -312,6 +316,10 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
const summary = sessions.find(candidate => candidate.sessionId === sessionId)
if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`)
const content = recordArray(request, 'content').map((part) => {
if (isRecord(part) && part.type === 'file') {
const image = stagedImages.get(recordString(part, 'receiptId'))
return image === undefined ? part : { type: 'image', attachment: image }
}
if (!isRecord(part) || part.type !== 'image') return part
const attachmentId = `assembled:${randomUUID()}`
const data = recordString(part, 'data')
@@ -364,7 +372,28 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
})
})
return { mock }
const fileUpload = async (input: string | URL, init: RequestInit): Promise<Response> => {
const url = new URL(String(input), 'http://assembled.test/')
const mediaType = url.searchParams.get('mediaType')
if (!url.pathname.endsWith('/api/session/uploadFileBinary') || mediaType === null || !(init.body instanceof Blob)) {
throw new Error(`assembled fixture: unexpected upload ${url.pathname}${url.search}`)
}
const bytes = new Uint8Array(await init.body.arrayBuffer())
const name = url.searchParams.get('name')
const image = {
attachmentId: `assembled:${randomUUID()}`,
mediaType,
bytes: bytes.byteLength,
width: 160,
height: 90,
...name === null ? {} : { name },
}
attachments.set(image.attachmentId, ok({ attachment: image, data: Buffer.from(bytes).toString('base64') }))
const receiptId = `assembled-receipt:${randomUUID()}`
stagedImages.set(receiptId, image)
return new Response(JSON.stringify({ ok: true, value: { receiptId, image } }), { status: 200 })
}
return { mock, fileUpload }
}
function eventOf(
+1
View File
@@ -78,6 +78,7 @@ describe('web e2e: blank Session reuse under the requested preset', () => {
}
async function storedSelections(sessionId: SessionId): Promise<unknown[]> {
await scaffold.ctx.sessionPersistence.flush()
const handle = await scaffold.ctx.sessionPersistence.open(sessionId, 'read')
try {
const { events } = await handle.read()
@@ -2,7 +2,7 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { chromium, webkit, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold,
@@ -12,6 +12,45 @@ import { openSettings, connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCA
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])(
'keeps a foreign model drag from editing another provider input in $name', async ({ engine }) => {
const scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
let browser: Browser | undefined
try {
browser = await engine.launch()
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('dialog', { name: '开始使用', exact: true }).getByRole('button', { name: '添加 API Key', exact: true }).click()
await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).getByRole('button', { name: '稍后配置' }).click()
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置', exact: true })
await dialog.getByRole('button', { name: '模型', exact: true }).click()
const setup = dialog.getByText('DeepSeek', { exact: true }).locator('xpath=ancestor::li')
await setup.getByText('自定义设置', { exact: true }).click()
await dialog.getByRole('button', { name: '添加模型提供商' }).click()
await dialog.getByRole('tab', { name: '自定义模型 API' }).click()
const custom = dialog.getByRole('tabpanel', { name: '自定义模型 API' })
await custom.getByRole('button', { name: '添加模型', exact: true }).click()
const target = custom.getByLabel('模型 ID 1', { exact: true })
await target.fill('foreign-model')
const before = scaffold.ctx.settings.describe({ redactSecrets: true }).map(row => ({ ns: row.ns, value: row.value }))
const source = setup.getByRole('button', { name: '调整模型顺序 1', exact: true })
await source.dragTo(target)
expect(await target.inputValue()).toBe('foreign-model')
expect(await setup.getByLabel('模型 ID 1', { exact: true }).inputValue()).toBe('deepseek-flash')
expect(scaffold.ctx.settings.describe({ redactSecrets: true }).map(row => ({ ns: row.ns, value: row.value }))).toEqual(before)
expect(tripwire.pageErrors).toEqual([])
} finally {
try {
await browser?.close()
} finally {
await scaffold.close()
}
}
}, 60_000,
)
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages settings', () => {
let scaffold: WebScaffold
let browser: Browser
+55 -1
View File
@@ -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>()
@@ -25,6 +25,7 @@
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 正在使用适配器默认模型
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: deepseek-flash
@@ -33,6 +34,7 @@
- text: DeepSeek-V4.1-Flash
- button "模型选项 1"
- button "删除模型 1"
- button "调整模型顺序 2"
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: deepseek-v4-pro
@@ -32,6 +32,7 @@
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "获取可用模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: gpt-6-astra
@@ -40,6 +40,7 @@
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "获取可用模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: acme-large
@@ -40,6 +40,7 @@
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "获取可用模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: acme-large
@@ -25,6 +25,7 @@
- text: 请填写兼容 Anthropic Messages 协议的 API 地址。
- region "模型目录":
- text: 模型目录 正在使用适配器默认模型
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: deepseek-flash
@@ -46,6 +47,7 @@
- text: 文本
- checkbox "图片" [checked]
- text: 图片
- button "调整模型顺序 2"
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: deepseek-v4-pro
@@ -26,6 +26,7 @@
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
- button "调整模型顺序 1"
- textbox "模型 ID 1":
- /placeholder: 模型 ID
- text: private-preview
@@ -14,6 +14,11 @@
- button "Asked questions" [expanded]
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
- button "Ask question 1/1 answered"
- group "Questions and answers":
- term: Checkpoint Ready to continue?
- definition: "Yes"
- text: {{clock}}
- button "Copy"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy"
- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
@@ -12,6 +12,11 @@
- status: Completed
- button "Completed in {{duration}}" [disabled]
- button "Asked questions"
- group "Questions and answers":
- term: Checkpoint Ready to continue?
- definition: "Yes"
- text: {{clock}}
- button "Copy"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy"
- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
@@ -0,0 +1,21 @@
/** Test theme that replaces one product icon with two-tone artwork painted from theme tokens. */
window.__ModuleLoader__.load({
id: '@fixture/icon-skin',
factory() {
const icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
+ '<circle data-skin-disc="" cx="8" cy="8" r="6" fill="var(--dsw-alias-state-success-primary)"/>'
+ '<path d="M5 8h6" stroke="currentColor"/></svg>'
return {
inject: ['theme'],
apply(ctx) {
ctx.effect(() => ctx.theme.register({
id: 'fixture-icon-skin',
colorScheme: 'light',
tokens: {},
icons: { 'dsw-icon-search-outline-regular': icon },
}))
ctx.theme.setTheme('fixture-icon-skin')
},
}
},
})
Loaded 100 of 681 files, more files were not shown because too many files have changed in this diff. Show more