Merge remote-tracking branch 'origin/master' into worktree/sync-dsh-0.2.1-rc.4-to-master

This commit is contained in:
imccyu
2026-10-27 02:21:20 +08:00
405 changed files with 36349 additions and 820 deletions
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-09-forge-panel.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-09-forge-panel.md
/agent-note-change-request-panel-and-worktree-sessions:
en: a524673f0d9cc9d8
zh: db8a2c1ad8f9c8a3
/agent-note-change-request-panel-and-worktree-sessions/problem:
en: 344bcc7e9825bbc8
zh: c1d0f4b54f5b3786
/agent-note-change-request-panel-and-worktree-sessions/decision:
en: b18b287b701aef93
zh: 462c0f561f1a0c18
/agent-note-change-request-panel-and-worktree-sessions/deferred:
en: 32ef8af85f4d2f66
zh: b82b67f94346399e
/agent-note-change-request-panel-and-worktree-sessions/alternatives-considered:
en: 91b3338f958ad0e9
zh: b64f30d333280084
/agent-note-change-request-panel-and-worktree-sessions/consequences:
en: 97976f073b9f65e0
zh: 95be3f7f375b0876
@@ -0,0 +1,84 @@
# Agent Note: Change request panel and worktree Sessions
Status: implemented
English | [中文](2026-10-09-forge-panel.zh.md)
## Problem
[Session change request bindings](2026-10-09-session-change-request-bindings.md) give each Session a list of change requests, but nothing shows it. A user who asks the agent to open change requests needs their checks, reviews, and mergeability without leaving the conversation, and wants a new Session's edits and commits kept out of the current checkout. Git must run where the Session's commands run, which can be a remote or confined execution world; inspection must not change the repository, and a worktree must exist before the model's first action.
## Decision
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry.
The Remote inspects the checkout through the Session's subprocess provider under the Session's read-only sandbox policy, so it reads what the Session's commands see and cannot write. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun.
Status polling follows panel subscriptions: a Session's bindings are watched only while one of its surfaces is mounted, and the registry stops polling change requests nobody watches. Each card keeps the last snapshot a poll returned, so a transient failure keeps the title and number visible. A Session's only open change request starts expanded, and with several every card starts collapsed; the user's first toggle replaces this default.
An expanded card marks a draft ready for review and merges a change request the host would merge now. The forge seam carries both as provider actions that return a typed failure, and every snapshot says whether the host would merge the change request now for the signed-in user and which methods the repository allows, so the panel offers only actions that can succeed. A merge carries the head commit the card showed, and the host refuses it once new commits arrived. The panel acts only on change requests bound to the Session.
A new Session starts in a worktree through a request, not an immediate creation. Checking the option in the New Session row, a `conversation.hero.options` entry, appends a `plugin:client-ui-forge/worktree` record with the base branch; changing the base or clearing the option appends another, and nothing touches the repository until the user sends. The option is offered only where creation can succeed: under `workspace-write`, the checkout and its common Git directory must lie inside the writable root, which excludes a project that is itself a linked worktree. The worktree must exist before the first message's turn starts, so the turn runs there and its first request already names the new directory. When the message enters the Session's inbox, the panel's `agent/inbox/inserted` listener starts maintenance of the Agent before the delivery wakes it; the Agent latches the wake until the maintenance ends, and the panel creates and enters the worktree inside it. The creation's stage streams to a card above the composer, where Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting in the inbox, and the card keeps the failure and offers another attempt with a new branch name or Continue without a worktree, which withdraws the request and lets the message go on. An `agent/pre-step` listener still creates the worktree at the first step proposal of a Session whose request is open, because a message that enters while the Agent runs a turn or other maintenance gets no hold; it moves the working-directory notice into the proposed messages, and a failure ends the turn. The `forgeWorktree` projection ignores requests after the first `turn/start` and creations after the first `step/start`, so a running Session never moves. The tab's first row names the branch the Session works on, and the Session list marks a Session that works in its created worktree.
The worktree a Session works in can be removed outside it. Each inspection checks whether the created worktree's directory still exists, and every message that enters while the Agent is idle starts a hold that checks again, so the message waits without starting its turn while the directory is missing. The check must come before the turn: while it assembles a step's context, the working-directory service moves a Session whose directory is gone back to its project, before any pre-step listener runs. Restore checks the branch out again at the recorded directory, or creates the branch again at its base commit when it is gone; Continue in the project moves the Session to the source checkout. Either runs inside the hold and lets the waiting message go on.
The panel does not move a Session between existing worktrees. Removing a change request appends a `dismiss` record, and its toast offers Undo through `track`.
The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from confined `git diff` in the Session's execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other.
## Deferred
A left-navigation review page next to Plugins and Scheduled tasks, listing the user's pull requests or merge requests on the selected provider, is deferred. It attaches without changing this package's Session surfaces:
- **Provider capability** — an optional `ForgeProvider` method such as `listForViewer(host, filter, signal)` returns change request references; GitHub implements it with `gh search prs --author @me` or the GraphQL `viewer`, and the registry aggregates enabled providers.
- **Remote** — a stream like `follow` that is keyed by provider and filter instead of a Session and polls through the same `ctx.forge.watch`.
- **Client** — a `sidebar.panellist` entry with a `main` page in this package, as `ui-schedule` does, rendering `ChangeRequestCard`, which takes a change request, its snapshot, and optional actions.
- **Settings** — provider choice and hosts on the bundle's configuration page.
The changes view offers no scope for one commit or for the last turn yet; both would be further `ForgeLocalScope` kinds read through `localDiff`.
## Alternatives considered
**Per-host nouns.** Calling items pull requests on GitHub and merge requests on GitLab would need each provider to declare its noun and every string to take it; GitHub is the only provider, and one term keeps the copy fixed.
**Merging through `gh pr merge`.** It enables auto-merge while required checks are pending and joins a merge queue when the base requires one, so a click could schedule a later merge instead of merging. The REST merge endpoint merges at once or refuses.
**Deleting the head branch after a merge.** The Session's worktree can be on that branch.
**A Host service package for Git inspection.** No second consumer needs checkout facts yet; a model tool can move them into a shared service when it exists.
**Running Git as a Host process.** Remote and confined execution worlds would show the Host's files instead of the Session's, and the Host would read directories the Session cannot.
**Creating the worktree when the option is checked.** Every toggle or base change would create and abandon a checkout, and the Session would move before the user decided to send.
**Carrying the option in the Session creation request.** The Session controller owns a closed request; a plugin record keeps the option inside this package and survives a reload of the blank Session.
**Switching between existing worktrees from the panel.** A switched Session keeps the sandbox root of its project, so a worktree outside the project stays read-only, and moving mid-conversation splits the model's view of the files. Starting a Session in its worktree avoids both.
**The option in the composer tool row.** The tool row stays after the first message, when the option no longer applies; the New Session row disappears once the Session starts. The row offered only single slots, so `ui-conversation` gained the list slot `conversation.hero.options`.
**Creating the worktree within the first turn.** The turn has started when the first step proposal runs: its context was assembled for the old directory, a failure can only end the turn and drop the claimed message, and the working-directory service has already moved a Session out of a removed worktree. It remains the fallback for a message no hold took.
**Holding the message at Agent admission.** An `agent-controller/admission` stage would hold a message before it enters the inbox, so the composer keeps it until the worktree exists and a failure returns it for editing. It needs a controller service in front of every sender's delivery, a core addition that one panel does not justify; a hold at inbox insertion uses the existing inbox event and maintenance, and covers every sender.
**Returning a waiting message to the composer.** Once the Host accepted a message the composer cannot take it back without a new public API in the conversation package for one plugin; the message keeps waiting with the card's actions instead.
**A submit gate in the Web composer.** The browser held the message before the Host saw it, so every check needed a Remote call, the stage of a refused message lived only in the browser, and other senders bypassed it. A hold at inbox insertion keeps the message where it enters the Agent and the failure in the panel state.
**The creation card in the transcript.** Transcript nodes accept no plugin kinds; the input dock above the composer is the nearest list slot, and it renders on the New Session screen.
**Manual URL tracking.** It duplicated the bindings the Session's own commands record. Bindings come from `gh pr create`, the `gh pr` commands that act on one change request, pushes, and the branch the checkout tracks, and `track` remains for Undo.
**Parsing patches in the browser.** The Remote would carry unbounded patch text, and the browser would need a patch parser; the Host bounds the read and parses it once.
**Reusing the review tab's file comparison.** It belongs to another plugin, which this package may not import at run time, and moving it into the shared primitives would change a shared package for one consumer; the view is built from the shared highlighter, menu, tooltip, and diff color tokens instead.
**Reading a change request's diff from the local repository.** The checked-out branch can be ahead of or behind the host, and the base may not be fetched; the host's diff is what reviewers see. The view says when the checked-out branch has commits beyond the head instead.
**Recognizing a too-large diff from the failure message in the panel.** The message belongs to the provider and its host and changes with them; a failure code lets each provider report its own limits, and the panel branches on the code.
**Creating the change request from the card.** The panel would need to push the branch and compose the title and description; a message lets the Agent do both with its tools and the user's instructions, and keeps one way to bind change requests.
## Consequences
The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to eleven short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once.
@@ -0,0 +1,84 @@
# Agent Note: 变更请求面板与 worktree Session
Status: implemented
[English](2026-10-09-forge-panel.md) | 中文
## Problem
[Session 变更请求绑定](2026-10-09-session-change-request-bindings.zh.md)为每个 Session 提供了变更请求列表,但没有地方展示。让 agent 开变更请求的用户需要在不离开对话的情况下看到它们的检查、评审与能否合并,也希望新 Session 的修改和提交不影响当前检出。Git 必须在 Session 命令运行的地方执行,这可能是远程或受限的执行环境;检查不能改动仓库,而且 worktree 必须在模型第一次动作之前就存在。
## Decision
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。
Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略下检查检出,因此它读到的与 Session 命令看到的相同,且无法写入。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。
状态轮询跟随面板订阅:只有该 Session 的某个界面挂载时才监听其绑定,注册表停止轮询无人监听的变更请求。每张卡片保留最近一次轮询得到的快照,因此短暂的失败不会让标题与编号消失。Session 只有一个打开的变更请求时,它的卡片默认展开;有多个时全部折叠;用户第一次展开或收起卡片后,以用户的选择为准。
展开的卡片可以把草稿标记为可评审,也可以合并主机现在就会合并的变更请求。forge seam 把两者作为提供方操作承载,失败时返回带类型的结果;每个快照都说明主机现在是否会为已登录用户合并该变更请求、仓库允许哪些合并方式,因此面板只提供能够成功的操作。合并带上卡片显示的 head 提交,有新提交到达后主机会拒绝。面板只操作绑定到该 Session 的变更请求。
新 Session 通过请求而不是立即创建来以 worktree 开始。勾选新会话那一行中的选项(一个 `conversation.hero.options` 条目)会追加一条带基准分支的 `plugin:client-ui-forge/worktree` 记录;更换基准或取消勾选会再追加一条;用户发送之前不会改动仓库。只有创建能够成功时才提供该选项:在 `workspace-write` 下,检出及其公共 Git 目录必须位于可写根之内,这排除了本身就是链接 worktree 的项目。worktree 必须在第一条消息的这一轮开始之前就存在,这样这一轮才会在其中运行,第一次请求也已经写明新目录。消息进入 Session 的收件箱时,面板的 `agent/inbox/inserted` 监听器在这次投递唤醒 Agent 之前启动 Agent 的维护任务;Agent 会暂存唤醒直到维护任务结束,面板在维护任务内创建并进入 worktree。创建的阶段以流的形式推送给输入框上方的卡片,卡片上的“取消”停止这次尝试。尝试被停止或失败后,消息继续在收件箱中等待,卡片保留失败信息,并提供用新分支名再试一次,或“不使用工作树”:撤回请求并让消息继续。一个 `agent/pre-step` 监听器仍会在请求未完成的 Session 的第一个步骤提议中创建 worktree,因为 Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留;它把工作目录通知移入本步提议的消息,失败时这一轮结束。`forgeWorktree` 投影忽略第一个 `turn/start` 之后的请求以及第一个 `step/start` 之后的创建,因此运行中的 Session 永远不会移动。标签页顶部一行写出 Session 所在的分支,会话列表标出在所创建 worktree 中工作的 Session。
Session 所在的 worktree 可能在 Session 之外被删除。每次检查都会确认所创建 worktree 的目录是否还在;Agent 空闲时进入的每条消息都会启动一次暂留并再确认一次,目录缺失期间消息等待、不开始这一轮。这次确认必须发生在这一轮之前:组装一个步骤的上下文时,工作目录服务会把目录已不存在的 Session 移回其项目,而这发生在任何 pre-step 监听器运行之前。“恢复”在记录的目录重新检出分支;分支也已删除时,在其基准提交重新创建分支。“在项目目录继续”把 Session 移回源检出。两者都在暂留内运行,之后放行等待中的消息。
面板不在现有 worktree 之间移动 Session。移除变更请求会追加一条 `dismiss` 记录,它的 toast 通过 `track` 提供“撤销”。
面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自 Session 执行环境中受限的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。
## Deferred
在左侧导航“插件”“自动化任务”旁边新增代码审查页、列出用户在所选提供方上的 pull request 或 merge request,这项工作推迟。它的接入不需要改动本包面向 Session 的界面:
- **提供方能力**——`ForgeProvider` 增加一个可选方法,例如 `listForViewer(host, filter, signal)`,返回变更请求引用;GitHub 用 `gh search prs --author @me` 或 GraphQL `viewer` 实现,注册表汇总已启用的提供方。
- **Remote**——一条类似 `follow` 的流,以提供方与筛选条件而不是 Session 为键,通过同一个 `ctx.forge.watch` 轮询。
- **客户端**——在本包中以 `sidebar.panellist` 条目加一个 `main` 页面实现(与 `ui-schedule` 相同),渲染接收变更请求、其快照与可选操作的 `ChangeRequestCard`。
- **设置**——在套件的配置页中选择提供方与主机。
变更视图目前还没有单个提交与上一轮的范围;两者将是经 `localDiff` 读取的更多 `ForgeLocalScope` 种类。
## Alternatives considered
**按平台使用不同名称。** 在 GitHub 上称 pull request、在 GitLab 上称 merge request,需要每个提供方声明自己的名称,所有文案也要随之变化;而 GitHub 是唯一的提供方,统一的术语让文案保持不变。
**通过 `gh pr merge` 合并。** 必需检查尚未完成时它会启用自动合并,基准分支要求合并队列时它会加入队列,因此一次点击可能安排了一次以后的合并而不是立即合并。REST 合并接口要么立即合并,要么拒绝。
**合并后删除 head 分支。** Session 的 worktree 可能就在这个分支上。
**为 Git 检查单独建一个宿主服务包。** 目前没有第二个使用方需要检出信息;出现模型工具时,可以再把它们移入共享服务。
**以宿主进程运行 Git。** 远程与受限的执行环境会显示宿主的文件而不是 Session 的文件,宿主还会读取 Session 无法读取的目录。
**勾选时立即创建 worktree。** 每次切换勾选或更换基准都会创建并丢弃一个检出,而且用户决定发送之前 Session 就已经移动。
**在创建 Session 的请求中携带该选项。** 这个请求由 Session 控制器拥有,字段是封闭的;插件记录让选项留在本包之内,并且在空白 Session 重新加载后仍然保留。
**在面板中切换现有 worktree。** 切换后的 Session 仍保留其项目的沙箱根,因此项目之外的 worktree 依然只读;对话中途移动还会让模型对文件的认识前后割裂。让 Session 从一开始就在自己的 worktree 中运行可以同时避免这两点。
**把选项放在输入框工具栏。** 发出第一条消息后工具栏依然存在,而那时该选项已不再适用;新会话那一行在 Session 开始后就会消失。那一行原先只提供单值 slot,因此 `ui-conversation` 增加了列表型 slot `conversation.hero.options`。
**在第一轮之内创建 worktree。** 第一个步骤提议运行时这一轮已经开始:上下文按旧目录组装,失败只能结束这一轮并丢弃已认领的消息,而工作目录服务已经把 Session 移出被删除的 worktree。它只作为没有被暂留的消息的兜底。
**在 Agent 接纳阶段暂留消息。** `agent-controller/admission` 阶段会在消息进入收件箱之前暂留它,因此 worktree 建好之前消息一直留在输入框里,失败时退回以便编辑。它需要在每个发送方的投递之前加一个控制器服务,单个面板不足以支撑这项核心改动;在收件箱插入时暂留只用现有的收件箱事件与维护任务,并覆盖所有发送方。
**把等待中的消息退回输入框。** 宿主接收消息之后,输入框没有办法把它收回,除非为一个插件在会话包里新增公共 API;消息改为在卡片的操作下继续等待。
**在 Web 输入框里设发送闸门。** 浏览器在宿主看到消息之前就拦住它,因此每次检查都要一次 Remote 调用,被拒绝消息的阶段只存在于浏览器中,其他发送方也会绕过它。在收件箱插入时暂留,让消息停在它进入 Agent 的位置,并把失败保留在面板状态中。
**把创建卡片放进对话记录。** 对话记录的节点不接受插件定义的类型;输入框上方的停靠区是最近的列表型 slot,而且它在新会话页也会渲染。
**手动粘贴 URL 跟踪。** 它与 Session 自己的命令记录下的绑定重复。绑定来自 `gh pr create`、操作单个变更请求的 `gh pr` 命令、推送以及检出所跟踪的分支,`track` 保留给“撤销”使用。
**在浏览器中解析补丁。** Remote 将传输不受限的补丁文本,浏览器也需要补丁解析器;宿主约束读取并只解析一次。
**复用代码审查标签页的文件对比。** 它属于另一个插件,本包在运行时不能导入;把它移入共享基础组件又会为一个使用方改动共享包。变更视图改用共享的高亮器、菜单、提示与 diff 颜色 token 构建。
**从本地仓库读取变更请求的 diff。** 当前检出分支可能领先或落后于托管平台,基准分支也可能没有拉取;评审者看到的是托管平台的 diff。视图改为在当前检出分支有超出 head 的提交时给出说明。
**在面板里根据失败信息识别 diff 过大。** 失败信息属于提供方及其托管平台,会随它们变化;失败代码让每个提供方报告自己的上限,面板只按代码分支。
**直接在卡片上创建变更请求。** 面板需要推送分支并撰写标题与描述;发送消息让 Agent 用自己的工具和用户的指示完成这两件事,绑定变更请求的方式也只保留一种。
## Consequences
面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十一个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-09-session-change-request-bindings.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-09-session-change-request-bindings.md
/agent-note-session-change-request-bindings:
en: 89ec58adddf53fec
zh: 919e8557d9923fdb
/agent-note-session-change-request-bindings/problem:
en: e736740546fb8219
zh: 46a16a7a633716ec
/agent-note-session-change-request-bindings/decision:
en: 2bbd058f4edc8bb5
zh: 5cc12bb0aa0864e2
/agent-note-session-change-request-bindings/alternatives-considered:
en: 15fdaef4e432fc72
zh: 8d36c859bd89f403
/agent-note-session-change-request-bindings/consequences:
en: a2960e50ec52c9b6
zh: fd23f4fe1137787d
@@ -0,0 +1,31 @@
# Agent Note: Session change request bindings
Status: implemented
English | [中文](2026-10-09-session-change-request-bindings.zh.md)
## Problem
A Session that opens or updates pull requests leaves them only in its shell output. Neither the user nor later consumers have a list of the change requests the Session works on or a shared view of their status. Code hosts differ in URLs, command-line tools, and status models, and any association must survive fork and resume without changing what the model sees.
## Decision
The experimental forge capability seam has three roles. [`dsh-experimental-forge`](../../../../packages/experimental/forge/README.md) defines provider-neutral repository and change request references, a provider registry, and shared polling: watchers of one change request share one loop, each forge has one batched fetch in flight, and the cadence follows check, mergeability, and lifecycle state. [`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.md) is the first provider; it reads GitHub through the GitHub CLI on the host, so credentials stay in that tool's own store and never enter Session execution worlds. [`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.md) binds change requests to Sessions.
Bindings derive from the Session log instead of a separate store. A Session projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results record the branches they updated. Those three commands act on one change request, so only the URL given as their first argument and the URL their output prints for that change request count; links in a description name other change requests. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated.
After each push result, and when a caller such as the forge panel reports the branch a Session's checkout tracks, the service looks up open change requests for that branch through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. The projection's client view therefore carries bindings without status.
## Alternatives considered
**A host-side binding database.** A separate store would need its own fork, resume, and deletion rules; plugin records and projections already provide them.
**A model tool that registers each change request.** The association would depend on the model calling an extra tool, and every request would carry its schema. Parsing the commands the model already runs costs no tokens.
**Logging polled status in the Session.** Status changes independently of the conversation. Logging it would grow the log with every poll and expose stale snapshots to later readers.
**Running `gh` through the Session's subprocess provider.** Remote and sandboxed execution worlds would need GitHub credentials, and status would depend on the world a Session uses. The host CLI keeps one credential owner.
## Consequences
Bindings appear without model cooperation for the common `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` flows and add no model tokens. Commands run through scripts, aliases, or other command-line tools are not recognized, and a change request opened elsewhere for a branch appears only after another push, another report of that branch, or a `track`. Another provider adds its own access and URL forms behind the same provider contract without changing the binding rules or the polling. The host needs `gh` signed in for each GitHub host.
@@ -0,0 +1,31 @@
# Agent Note: Session 变更请求绑定
Status: implemented
[English](2026-10-09-session-change-request-bindings.md) | 中文
## Problem
打开或更新 pull request 的 Session 只在 shell 输出里留下它们。用户和后续消费方都无法得到该 Session 正在处理的变更请求(change request)列表,也无法共享查看其状态。各代码托管平台的 URL、命令行工具与状态模型各不相同,而这种关联必须在 fork 与恢复后保留,且不能改变模型看到的内容。
## Decision
实验性的 forge 能力 seam 由三个角色组成。[`dsh-experimental-forge`](../../../../packages/experimental/forge/README.zh.md) 定义提供方无关的仓库与变更请求引用、提供方注册表与共享轮询:同一变更请求的监听方共享一个循环,每个 forge 同时只有一次批量读取,轮询节奏随检查、可合并性与生命周期状态变化。[`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.zh.md) 是第一个提供方;它通过宿主上的 GitHub CLI 读取 GitHub,因此凭据保留在该工具自己的存储中,从不进入 Session 的执行环境。[`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.zh.md) 把变更请求绑定到 Session。
绑定从 Session 日志派生,而不是存放在单独的存储中。一个 Session 投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`git push` 的结果记录其更新的分支。这三个命令只操作一个变更请求,因此只计入作为第一个参数给出的 URL 和输出为该变更请求打印的 URL;描述正文里的链接指向的是其他变更请求。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。
每次推送结果之后,以及 forge 面板等调用方报告 Session 检出所跟踪的分支时,服务通过提供方为该分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。因此投影的客户端视图只带绑定,不带状态。
## Alternatives considered
**宿主侧绑定数据库。** 单独的存储需要自己的 fork、恢复与删除规则;插件记录与投影已经提供了这些规则。
**由模型工具登记每个变更请求。** 关联将取决于模型是否调用额外的工具,而且每个请求都要携带该工具的 schema。解析模型本来就会运行的命令不消耗 token。
**把轮询状态写入 Session 日志。** 状态的变化独立于对话。写入日志会让日志随每次轮询增长,并把过期快照暴露给后续读取方。
**通过 Session 的子进程提供方运行 `gh`。** 远端与沙箱执行环境将需要 GitHub 凭据,状态也会取决于 Session 使用的执行环境。宿主 CLI 让凭据只有一个所有者。
## Consequences
在常见的 `gh pr create`、`gh pr view`、`gh pr edit`、`gh pr checkout` 与 `git push` 流程中,绑定无需模型配合即可出现,且不增加模型 token。通过脚本、别名或其他命令行工具运行的命令无法识别;在别处为某个分支打开的变更请求,要等到下一次推送、再次报告该分支或 `track` 之后才会出现。新的提供方在同一提供方契约之后加入自己的访问方式与 URL 形式,无需改变绑定规则或轮询。宿主需要为每个 GitHub 主机登录 `gh`。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-20-forge-session-list.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-20-forge-session-list.md
/agent-note-forge-state-in-the-session-list:
en: b5876f7873857921
zh: 56fac53370de206d
/agent-note-forge-state-in-the-session-list/problem:
en: 07ba0c3bf73a2446
zh: 2413783a6697133e
/agent-note-forge-state-in-the-session-list/decision:
en: e1d03cf4e1afd5aa
zh: b2386cb7c17c58dd
/agent-note-forge-state-in-the-session-list/alternatives-considered:
en: 3b08440ee2bf7809
zh: a90e3fcb89cf3f74
/agent-note-forge-state-in-the-session-list/consequences:
en: 25a17eac43beb705
zh: c138f693ff07c91d
@@ -0,0 +1,39 @@
# Agent Note: Forge state in the Session list
Status: implemented
English | [中文](2026-10-20-forge-session-list.zh.md)
## Problem
The [forge panel](2026-10-09-forge-panel.md) named a Session's branch in a chip beside the Session title. The header's action row does not shrink, so a long branch overlapped other header entries such as the background task label. Finding which Sessions work in their own worktrees, and which of their change requests need attention, also meant opening each Session's tab. The Session list offers seats for row icons and a hover card section, `sidebar.session.row.trailing` and `sidebar.session.row.hover`, whose occupants must not load or bind Sessions. A row needs two kinds of fact: what the Session log records, which is the worktree the panel created and entered and the change requests the Session binds, and what only polling knows, which is each change request's state and checks.
## Decision
The chip beside the Session title is gone, and the tab's first row names the branch. A Session row shows, after its time, a branch glyph while the Session works in the worktree the panel created for it, and the glyph of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. An open change request's glyph keeps the row's subdued color with a dot for its checks; merged and closed ones keep their state colors. The row's hover card section names the worktree branch and what it started from, then up to three change requests with their state and checks, and how many more there are.
Log facts reach the list as projection client views, which the Session summary carries. The `forgeWorktree` projection also records the Session's directory from its header and each `working-directory/change`, and its view is the created worktree while that directory is the worktree or lies below it, else null. The `forgeChangeRequests` view lists the bindings without status, as the [binding decision](2026-10-09-session-change-request-bindings.md) intended. The projection cache keeps both for Sessions that are not loaded.
Polled states reach the list through the `forgePanel.changeRequestStates` stream. One Host run serves every stream and stops with the last one. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of loaded Sessions from `ctx.forgeSession` and those of other Sessions from their cached summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through the shared `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. Every changed state is stored in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL, so a restart shows the last known states before the next poll, and unbound stored states are dropped only when every listed Session has known bindings. A stream sends a `baseline` of the states its caller may read, then `changed` frames, filtered through Remote access.
The browser keeps one source of states for every row and hover section: the first subscriber opens the stream, the last one closes it, and a transport reset reopens it.
## Alternatives considered
**Leaving change requests to the tab.** The forge panel first rejected a Session-row hover section because, for one Session, it repeated the tab. The list compares every Session at once, which no tab does, and the hover section adds only the facts a row's glyphs summarize.
**Keeping the branch beside the Session title.** Truncating the chip would hide the branch name, and the header shows only the open Session; the list shows every Session, and the tab names the branch in full.
**Polled states in a projection.** Projections fold the Session log, and polled states are not logged. A projection would either log every poll, adding events that no model request needs, or depend on process-local state that a replay cannot reconstruct.
**Following every listed Session's panel.** A panel inspects its checkout with Git in the Session's execution world and probes sign-in; the list needs only bindings and change request states.
**Loading listed Sessions to read their bindings.** Listing would replay every Session log; the cached summary answers without loading.
**Keeping the states only in memory.** Every row would show unknown states after a restart until each change request is polled again. The stored states are derived data, so a record that fails its schema is set aside instead of failing the run.
**A branch line for a Session that works in its project directory.** Reading that branch needs Git in the Session's execution world, which the list must not reach without loading the Session.
## Consequences
The list shows every Session's worktree and change request states without loading a Session, a restart shows the last known states at once, and the header no longer overlaps. While a row is shown, every open change request that a listed Session binds is polled, so many Sessions with open change requests mean more requests to the code host; closed requests keep polling at the idle cadence, and merged ones stop automatic polling. A Session without a cached summary shows its worktree and change requests in the list only after it is opened, and one last used before this change shows its worktree only then, because the `forgeWorktree` state changed and older cached rows are discarded. Checks that finish while no row is shown produce no notification.
@@ -0,0 +1,39 @@
# Agent Note: 会话列表中的 forge 状态
Status: implemented
[English](2026-10-20-forge-session-list.md) | 中文
## Problem
[forge 面板](2026-10-09-forge-panel.zh.md)原先在 Session 标题旁的标签中写出 Session 的分支。标题栏的操作行不会收缩,因此较长的分支名会与后台任务标签等其他标题栏条目重叠。想知道哪些 Session 在自己的 worktree 中工作、它们的哪些变更请求需要处理,也必须逐个打开 Session 的标签页。会话列表为行图标与悬浮卡片提供了位置,即 `sidebar.session.row.trailing` 与 `sidebar.session.row.hover`,其中的条目不得加载或绑定 Session。一行需要两类事实:Session 日志记录的事实,即面板创建并进入的 worktree 以及 Session 绑定的变更请求;以及只有轮询才知道的事实,即每个变更请求的状态与检查。
## Decision
Session 标题旁的标签已移除,由标签页顶部一行写出分支。Session 在面板为它创建的 worktree 中工作时,会话行在时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,并用圆点标出检查;已合并与已关闭的保留各自的状态颜色。会话行悬浮卡片中的部分写出 worktree 分支及其起点,再列出最多三个变更请求的状态与检查,并说明还有多少个。
日志事实经投影的客户端视图进入列表,由 Session 摘要携带。`forgeWorktree` 投影还从 Session 头部与每个 `working-directory/change` 记录 Session 的目录;该目录就是所创建的 worktree 或位于其下时,视图为该 worktree,否则为 null。`forgeChangeRequests` 视图列出绑定但不含状态,与[绑定决策](2026-10-09-session-change-request-bindings.zh.md)的设想一致。投影缓存为未加载的 Session 保留这两项。
轮询到的状态经 `forgePanel.changeRequestStates` 流进入列表。宿主用一次运行服务所有流,最后一条流结束时运行停止。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的摘要;它跟随已加载 Session 的绑定变化,并经共享的 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,因此重启后在下次轮询之前就能显示最近已知的状态;仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态。每条流先发送调用方可读状态的 `baseline`,之后发送 `changed` 帧,均经 Remote 访问控制过滤。
浏览器为所有会话行与悬浮卡片部分只保留一个状态来源:第一个订阅方打开流,最后一个关闭它,传输重置后重新打开。
## Alternatives considered
**把变更请求只留给标签页。** forge 面板最初否决了 Session 行悬浮分区,因为对单个 Session 而言它与标签页重复。列表能同时比较所有 Session,这是任何标签页都做不到的;悬浮部分也只补充会话行图标所概括的事实。
**保留 Session 标题旁的分支。** 截断标签会遮住分支名,而且标题栏只显示当前打开的 Session;列表显示每个 Session,标签页则完整写出分支。
**把轮询状态放进投影。** 投影折叠的是 Session 日志,而轮询到的状态不写入日志。放进投影要么为每次轮询记录事件,而这些事件没有任何模型请求需要;要么让投影依赖重放无法重建的进程内状态。
**为每个已列出的 Session 跟随其面板。** 面板会在 Session 的执行环境中用 Git 检查检出并检查登录状态;列表只需要绑定与变更请求状态。
**加载已列出的 Session 来读取绑定。** 列表将重放每个 Session 的日志;缓存的摘要无需加载就能回答。
**只把状态保存在内存中。** 重启后每一行都会显示未知状态,直到每个变更请求再次被轮询。已存状态是派生数据,因此不符合 schema 的记录会被放到一边,而不是让运行失败。
**为在项目目录中工作的 Session 显示分支一行。** 读取该分支需要在 Session 的执行环境中运行 Git,而列表不加载 Session 就无法到达那里。
## Consequences
列表无需加载 Session 就能显示每个 Session 的 worktree 与变更请求状态,重启后立即显示最近已知的状态,标题栏也不再重叠。有会话行显示期间,已列出 Session 绑定的每个打开中的变更请求都会被轮询,因此有大量带打开变更请求的 Session 时,对代码托管平台的请求会增多;已关闭的继续按空闲间隔轮询,已合并的停止自动轮询。没有缓存摘要的 Session 要打开之后才会在列表中显示其 worktree 与变更请求;此变更之前最后使用的 Session 也要到那时才显示其 worktree,因为 `forgeWorktree` 的状态已变化,旧的缓存行会被丢弃。没有会话行显示期间结束的检查不会有通知。
@@ -9,14 +9,14 @@
en: 46916488210ba493
zh: 98107c3805a22b2c
/agent-note-dual-release-install-layout-work-budget/decision:
en: c432b68c4ad1b3c1
zh: a11e958fa68dc2f3
en: b900469750b27f81
zh: d5380d7751332e9c
/agent-note-dual-release-install-layout-work-budget/what-the-resolution-costs:
en: 36d3e3ef39cbba2f
zh: 2480acf6260817b3
en: 463df1fae15ae2fc
zh: 733c9ddb4900e3fc
/agent-note-dual-release-install-layout-work-budget/alternatives-considered:
en: 29987b7634c84d28
zh: a1469e4b6aaa46e2
/agent-note-dual-release-install-layout-work-budget/consequences:
en: 8dffc0c3fa699980
zh: 99ec25001e5aa716
en: 4226e2216abe042d
zh: db8f308ef40f47e9
@@ -10,14 +10,16 @@ The `Dependency layout` lane packs two incompatible synthetic DSH releases into
## Decision
`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 875_000`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant.
`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 1_134_400`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant.
The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (910 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart.
The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (about 1,180 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart.
This restores the [published dependency faces](2026-08-26-published-dependency-faces.md) decision that `verify-npm-install-layout` does not enforce resolver duration; the 300 s deadline had contradicted it.
## What the resolution costs
Measured 2026-10-26 on an idle Apple M5 Pro with Node 24.17.0 and npm 11.13.0, using `buildDualDshRegistry`, `resolveNpmPackageLock`, and `assertDualDshInstallLayout` sequentially with fresh temporary consumers: the #5960 graph (315 packages per release, 2782 internal edges, 876,330 work units) resolved in 75.27 s; the #5961 graph (320 packages, 2836 edges, 907,520 units) resolved in 71.03 s, with a second verification at 97.73 s. All placements passed, shared one Cordis installation, and requested no archives. The measurements calibrate graph admission and do not establish a runtime improvement. The current budget is 1.25 times its 907,520 units; the earlier, slower per-unit timing remains the conservative hang-guard calibration.
Measured 2026-09-28 on an idle M-series host, against `origin/master` (277 DSH packages per release, 2524 internal edges), by running the lane's own npm invocation with `--timing` and `--cpu-prof`:
| Phase | Cost |
@@ -47,4 +49,4 @@ Repeated runs of the same graph on one host span 137.8 s to 204.8 s of npm time,
## Consequences
The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to 910 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires.
The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to about 1,180 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires.
@@ -10,14 +10,16 @@ Status: implemented
## Decision
`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 875_000` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。
`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 1_134_400` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。
npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(910 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。
npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(约 1,180 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。
这恢复了[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)中「`verify-npm-install-layout` 不限制 resolver 耗时」的决定;300 秒期限与该决定相矛盾。
## What the resolution costs
2026-10-26 在空闲 Apple M5 Pro 上使用 Node 24.17.0 与 npm 11.13.0,通过 `buildDualDshRegistry`、`resolveNpmPackageLock` 和 `assertDualDshInstallLayout`,对独立的新建临时消费者目录依次测量:#5960 的依赖图(每个发布 315 个包、2782 条内部边、876,330 个工作量单位)用 75.27 秒解析;#5961 的依赖图(320 个包、2836 条边、907,520 个工作量单位)用 71.03 秒解析,第二次验证耗时 97.73 秒。所有布局校验均通过,共享一个 Cordis 安装,未请求包归档。这些测量用于校准依赖图的准入规模,不代表运行时性能提升。当前预算为其 907,520 个工作量单位的 1.25 倍;挂起保护仍采用此前较慢的单位工作量耗时作为保守校准。
2026-09-28 在空闲的 M 系列主机上,针对 `origin/master`(每个发布 277 个 DSH 包、2524 条内部边),用 `--timing` 和 `--cpu-prof` 运行 lane 自身的 npm 调用测得:
| 阶段 | 开销 |
@@ -47,4 +49,4 @@ npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION
## Consequences
lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多 910 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。
lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多约 1,180 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。
@@ -21,11 +21,11 @@
en: f07bb6fbbdd01fe4
zh: 9313a0ef2a22af12
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
en: fcc52be9892b2bfc
zh: ac259aba407efcbc
en: 37faaffb4662fd54
zh: df0a4bf016393be9
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
en: 168bda1a4e9e0325
zh: af9cf7b804db84b7
en: 559a8037caa9e3aa
zh: 4f3719ed09445cc2
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences:
en: 8f9d25b72d3e7660
zh: e5c6ae0194905876
@@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md)
| Item | Content |
|---|---|
| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval |
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
@@ -54,6 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi
## Alternatives considered
- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion.
- **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit.
- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state.
- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure.
@@ -43,7 +43,7 @@ Status: implemented
| 项 | 内容 |
|---|---|
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
@@ -54,6 +54,7 @@ Status: implemented
## 考虑过的替代方案
- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。
- **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。
- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
+1 -1
View File
@@ -4,6 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win
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.
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job.
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates.
The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job.
+161
View File
@@ -0,0 +1,161 @@
name: Package Desktop · macOS · Intel
on:
workflow_dispatch:
inputs:
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
preset:
description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境'
type: choice
options: [test, production]
default: test
required: true
update_channel:
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
type: choice
options: [nightly, latest]
default: nightly
required: true
version_mode:
description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填'
type: choice
options: [default, product, auto, manual]
default: default
required: true
build_version:
description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀'
type: string
default: ''
required: false
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
compression_level:
description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强'
type: choice
options: ['3', '7']
default: '3'
required: true
skip_notarization:
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
type: boolean
default: false
required: true
permissions:
contents: read
concurrency:
group: macos-desktop-package
cancel-in-progress: false
jobs:
package:
runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial]
timeout-minutes: 180
defaults:
run:
shell: bash
env:
DSH_DESKTOP_TARGET_PLATFORM: darwin
DSH_DESKTOP_TARGET_ARCH: x64
PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }}
PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }}
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
PACKAGE_VERSION_MODE: ${{ inputs.version_mode }}
DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }}
DSH_TELEMETRY_DISABLED: '1'
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
steps:
- uses: actions/checkout@v7.0.1
id: checkout
with:
persist-credentials: false
clean: true
ref: ${{ inputs.source_ref }}
- name: Validate selected source and adapter support
run: >-
node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF;
const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET;
if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset');
const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref);
if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*');
if(tag) {
const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;
if(ref!=='dsh-v'+v) throw Error('Source tag must match product version');
const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim();
const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim();
if(tagged!==head) throw Error('Checkout does not match source tag');
} else {
const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim();
if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref');
}
if(!fs.readFileSync('apps/desktop/scripts/prepare-macos-ci.mjs','utf8').includes('resolveDesktopBuildTarget')) throw Error('Source lacks Intel macOS CI support; select a newer source');
if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');"
- uses: pnpm/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Configure persistent package caches
env:
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
run: |
echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV"
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
- name: Check Rosetta execution
run: arch -x86_64 /usr/bin/true
- name: Install locked dependencies
run: pnpm install --frozen-lockfile
- name: Validate preset and resolve build version
id: settings
env:
PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
run: node apps/desktop/scripts/prepare-macos-ci.mjs
- name: Check packaging configuration and toolchain
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: |
args=()
if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi
pnpm run package:desktop:mac:x64 --check --build-version "$PACKAGE_VERSION" "${args[@]}"
- name: Build signed installers
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: |
args=()
if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi
pnpm run package:desktop:mac:x64 --build-version "$PACKAGE_VERSION" "${args[@]}"
- name: Retain complete package outside the checkout
id: retained
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts
- name: Retain packaging diagnostics
if: always() && steps.checkout.outcome == 'success'
uses: actions/upload-artifact@v7.0.1
with:
name: macos-x64-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: apps/desktop/.desktop-build/packaging-runs/
if-no-files-found: warn
retention-days: 14
- name: Retain installers for the selected mode
if: success()
uses: actions/upload-artifact@v7.0.1
with:
name: macos-x64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: ${{ steps.retained.outputs.directory }}/*
compression-level: 0
if-no-files-found: error
retention-days: 14
+25 -23
View File
@@ -1,35 +1,19 @@
name: Package Desktop · macOS
name: Package Desktop · macOS · Apple Silicon
on:
workflow_dispatch:
inputs:
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
preset:
description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境'
type: choice
options: [test, production]
default: test
required: true
zip_compression_level:
description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强'
type: choice
options: ['3', '7']
default: '3'
required: true
skip_notarization:
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
type: boolean
default: false
required: true
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
update_channel:
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
type: choice
@@ -47,6 +31,22 @@ on:
type: string
default: ''
required: false
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
compression_level:
description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强'
type: choice
options: ['3', '7']
default: '3'
required: true
skip_notarization:
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
type: boolean
default: false
required: true
permissions:
contents: read
@@ -63,7 +63,9 @@ jobs:
run:
shell: bash
env:
PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.zip_compression_level }}
DSH_DESKTOP_TARGET_PLATFORM: darwin
DSH_DESKTOP_TARGET_ARCH: arm64
PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }}
PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }}
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
PACKAGE_VERSION_MODE: ${{ inputs.version_mode }}
+15 -4
View File
@@ -93,15 +93,26 @@ jobs:
node-version: '24'
package-manager-cache: false
- name: Run the pre-merge command
- name: Run the tree's pre-merge check
if: steps.merge.outputs.merged == 'true'
env:
CHECK_MODE: ${{ vars.MERGE_POLICY_PRE_MERGE }}
run: |
if ! bash -euxo pipefail -c 'pnpm install --frozen-lockfile && pnpm exec tsx scripts/compile-referenced-projects.ts host-contracts && pnpm exec tsx scripts/compile-referenced-projects.ts client'; then
# The check names one file and knows nothing else about how the merged tree builds itself.
if [ ! -f scripts/pre-merge-check.ts ]; then
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::the merged tree does not install or compile"
echo "::error title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked"
exit 1
fi
echo "::warning title=Pre-Merge Check::the merged tree does not install or compile; this check does not block"
echo "::warning title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked"
exit 0
fi
if node scripts/pre-merge-check.ts; then
exit 0
fi
# A check that crashes reports nothing, so the step reports its exit the way the mode asks.
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked"
exit 1
fi
echo "::warning title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked"
+23 -23
View File
@@ -1,30 +1,19 @@
name: Package Desktop · Windows
name: Package Desktop · Windows · x64
on:
workflow_dispatch:
inputs:
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
preset:
description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境'
type: choice
options: [test, production]
default: test
required: true
signing:
description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)'
type: choice
options: [signed, unsigned]
default: signed
required: true
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
update_channel:
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
type: choice
@@ -42,6 +31,23 @@ on:
type: string
default: ''
required: false
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
compression_level:
description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置'
type: choice
options: ['3', '7']
default: '3'
required: true
signing:
description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)'
type: choice
options: [signed, unsigned]
default: signed
required: true
cache_namespace:
description: '缓存测量命名空间:留空使用共享缓存;新名称用于冷缓存,再次使用同名测热缓存,不清除现有缓存'
type: string
@@ -52,12 +58,6 @@ on:
type: string
default: ''
required: false
compression_level:
description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置'
type: choice
options: ['3', '7']
default: '3'
required: true
permissions:
contents: read
+4
View File
@@ -134,6 +134,7 @@ External packages installed for runtime use or embedded in distributed Host or b
| [`semver`](https://github.com/npm/node-semver) | ISC |
| [`serve-static`](https://github.com/expressjs/serve-static) | MIT |
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
| [`shell-quote`](http://github.com/ljharb/shell-quote) | MIT |
| [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
@@ -163,6 +164,8 @@ pnpm applies local patches to the following packages at install time, so shipped
- `app-builder-lib@26.17.0` — [`patches/app-builder-lib@26.17.0.patch`](patches/app-builder-lib@26.17.0.patch)
- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch)
- `lexical@0.49.0` — [`patches/lexical@0.49.0.patch`](patches/lexical@0.49.0.patch)
- `mdast-util-gfm-autolink-literal@2.0.1` — [`patches/mdast-util-gfm-autolink-literal@2.0.1.patch`](patches/mdast-util-gfm-autolink-literal@2.0.1.patch)
- `micromark-extension-gfm-autolink-literal@2.1.0` — [`patches/micromark-extension-gfm-autolink-literal@2.1.0.patch`](patches/micromark-extension-gfm-autolink-literal@2.1.0.patch)
- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
## Chrome DevTools frontend
@@ -267,6 +270,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT |
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
| [`tencentcloud-sdk-nodejs-teo`](https://github.com/tencentcloud/tencentcloud-sdk-nodejs) | Apache-2.0 |
| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
| [`vite`](https://github.com/vitejs/vite) | MIT |
+2 -1
View File
@@ -108,7 +108,8 @@
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*",
"@deepseek-ai/dsh-cua-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*",
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*"
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*",
"@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/dsh-skill-badge": "workspace:*",
+4
View File
@@ -56,3 +56,7 @@ APPLE_API_ISSUER=
# DOWNLOAD_PROD_COS_BUCKET=
# DOWNLOAD_PROD_COS_SECRET_ID=
# DOWNLOAD_PROD_COS_SECRET_KEY=
# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity.
# Required for upload:latest:* and purge:latest; configure the download domain's EO zone.
# DOWNLOAD_PROD_EO_ZONE_ID=
+4
View File
@@ -41,3 +41,7 @@ DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
# DOWNLOAD_PROD_COS_BUCKET=
# DOWNLOAD_PROD_COS_SECRET_ID=
# DOWNLOAD_PROD_COS_SECRET_KEY=
# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity.
# Required for upload:latest:* and purge:latest; configure the download domain's EO zone.
# DOWNLOAD_PROD_EO_ZONE_ID=
+4 -2
View File
@@ -43,7 +43,8 @@
"upload:win:x64": "tsx scripts/upload-target.ts win-x64",
"upload:latest:mac:arm64": "tsx scripts/upload-target.ts mac-arm64 --latest",
"upload:latest:mac:x64": "tsx scripts/upload-target.ts mac-x64 --latest",
"upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest"
"upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest",
"purge:latest": "tsx scripts/upload-target.ts --latest --purge-only"
},
"dependencies": {
"electron-updater": "^6.8.9",
@@ -86,6 +87,7 @@
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"zod": "4.4.3",
"@deepseek-ai/dsh-util-file-drag": "workspace:*"
"@deepseek-ai/dsh-util-file-drag": "workspace:*",
"tencentcloud-sdk-nodejs-teo": "4.1.330"
}
}
+4 -4
View File
@@ -24,11 +24,11 @@
en: 5b4f0f80a117f202
zh: ff05be196c181087
/desktop-packaging/windows-actions-packaging:
en: 364223725374d9ac
zh: c4d4cc58d7cbafbd
en: 9dd2227f2878519e
zh: cd95dd6efd48298f
/desktop-packaging/macos-self-hosted-packaging:
en: bb1dcdfef3f3a113
zh: 9c151800a61e2505
en: ee628e830d849ae9
zh: 44313f5fb842f042
/desktop-packaging/unsigned-windows-test-installer:
en: f429bc0c280e2dfa
zh: 4adbcc9e906bdf80
+4 -2
View File
@@ -109,7 +109,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
For supervised runner startup and recovery, follow [Windows runner machine setup](../windows-signing/README.md#machine-setup).
The `Package Desktop · Windows` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `signed` (default) or `unsigned`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged.
The `Package Desktop · Windows · x64` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `signed` (default) or `unsigned`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged.
Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts.
@@ -127,7 +127,9 @@ Installer uploads store the already-compressed release files without ZIP recompr
## macOS self-hosted packaging
The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG.
The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG.
All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing, cache measurement and isolated-workspace options follow these common fields.
Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior.
+4 -2
View File
@@ -109,7 +109,7 @@ macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../
runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-signing/README.zh.md#machine-setup)。
`Package Desktop · Windows` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。
`Package Desktop · Windows · x64` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。
在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。
@@ -127,7 +127,9 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
## macOS 自托管打包
`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量、独立构建目录选项位于公共字段之后。
运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。
+6 -6
View File
@@ -18,17 +18,17 @@
en: b407de63118c9bf9
zh: de19f1ba9feacbbf
/desktop-publication/deployment-and-test-release-identity:
en: 405588e021bcfb60
zh: d9f9700b0b6db6f8
en: eed7c4ed0d4e2758
zh: 026fe6076e11018e
/desktop-publication/publish-installers-and-update-feeds:
en: 3c3e3a09573c4386
zh: c851ae6a94f2284a
/desktop-publication/fixed-installer-downloads:
en: 38054a5f89590cfa
zh: 9e115f9bdeeab213
en: ce8296f8f1ee881e
zh: 749ab63bd6ed4248
/desktop-publication/upload-credentials:
en: 1c5689525753b08d
zh: 93089de765806733
en: 47442fb912fa4d5d
zh: a21a0d67cd01724b
/desktop-publication/upload-audit-and-recovery:
en: ec85f43ece09a5e3
zh: 2de5bf801c9c9e39
+7 -3
View File
@@ -33,7 +33,7 @@ Publication requires completed release artifacts and explicit operator authoriza
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds/<target>/` and binaries use `dsh-desk/bin/<target>/`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk/<release-id>/feeds/<target>/` and `dsh-desk/<release-id>/bin/<target>/`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`.
The update destination and upload credentials follow the selected deployment:
The update destination and upload credentials follow the selected deployment; production fixed downloads also require the [EdgeOne settings](#fixed-downloads):
| Environment | Public origin | COS bucket | COS credentials |
|---|---|---|---|
@@ -69,13 +69,17 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the
## Fixed installer downloads
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit one EdgeOne URL purge and report its task ID; they do not query or wait for cache completion. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv and `teo:CreatePurgeTask` permission for that zone on the existing `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` identity. The release operator must confirm that permission before use; no additional CI Secret is required. Required settings are resolved before uploading. The submission has a 30-second request timeout and is never retried automatically.
The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest <target>` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The CI publisher does not publish fixed downloads or run this refresh.
<a id="credentials"></a>
## Upload credentials
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. For a cache-only submission, add `-PurgeOnly` to `-Upload -Latest -Environment production`. Deployment and bucket must still match the dotenv, while package artifacts are not read. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
<a id="audit-and-recovery"></a>
+7 -3
View File
@@ -33,7 +33,7 @@ description: "将完整 Desktop 安装包与更新清单发布到选定下载服
`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds/<target>/`,安装包位于 `dsh-desk/bin/<target>/`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk/<release-id>/feeds/<target>/` 和 `dsh-desk/<release-id>/bin/<target>/`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。
更新目标与上传凭据都与所选环境对应:
更新目标与上传凭据都与所选环境对应;生产固定下载还需配置 [EdgeOne 设置](#fixed-downloads):
| 环境 | 公开 origin | COS bucket | COS 凭据 |
|---|---|---|---|
@@ -69,13 +69,17 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo
## 固定安装包下载
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交一次 EdgeOne URL 缓存清除任务并报告任务 ID,不查询或等待缓存清除完成。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`,且既有 `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` 身份具备对应站点的 `teo:CreatePurgeTask` 权限。发布操作人员须在使用前确认该权限;无需新增 CI Secret。上传前解析必需配置。提交请求超时为 30 秒,不自动重试。
命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest <target>` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。CI 发布器不发布固定下载,也不执行此刷新。
<a id="credentials"></a>
## 上传凭据
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅提交缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
<a id="audit-and-recovery"></a>
+2 -2
View File
@@ -21,8 +21,8 @@
en: 0478d8e35f9a3d55
zh: 0aa3fd828bc02691
/desktop-ci-publication/machine-setup:
en: f80388c2b1ffef4c
zh: f242ab2316bf38a1
en: 6e7b928af17f4377
zh: 7c559078306df3fd
/desktop-ci-publication/package-and-publish:
en: f7c12d343891e547
zh: da596ee1366c2046
+1 -1
View File
@@ -56,7 +56,7 @@ Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging direct
Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration.
Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other.
Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`. Assign that label to exactly that runner. A target may be a single name or a nonempty list; the Mac entry above authorizes both packaging workflows on the same machine. Windows uses target `win-x64`. The retained target must match the selected packaging workflow. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other.
An administrator must create `desktop-test` and `desktop-production`, each with required reviewers and an allowed `master` deployment branch. The workflow checks reviewers for either deployment. Test can allow self-approval. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. The token action accepts key contents, so the masked PEM also passes through the runner's temporary `GITHUB_OUTPUT` file during the job; masking hides logs, not local file access.
+1 -1
View File
@@ -56,7 +56,7 @@ job 排队时,先对照 job 页面请求的标签与 **Settings → Actions
在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。
将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。
将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`。该标签只能分配给这台 runner。目标可为单个名称或非空列表;上述 Mac 条目允许同一台机器处理两种架构的打包工作流。Windows 使用目标 `win-x64`。留存包的目标必须与所选打包工作流一致。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。
管理员必须创建 `desktop-test` 和 `desktop-production`,分别设置必需审批人并允许 `master` 部署分支。工作流会检查两种环境的审批人配置。test 可允许发起者自行审批。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。
@@ -7,6 +7,13 @@ export type DesktopAutoUpdateEnvironment = 'test' | 'production'
/** Directory name of one supported Desktop release target. */
export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64'
/**
* Name the fixed installer object shared by uploading and cache refresh.
* @param target Supported Desktop target.
* @returns Fixed download filename.
*/
export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget): string
/** Public updater URL and object prefixes for one release target. */
export interface DesktopAutoUpdateConfig {
readonly environment: DesktopAutoUpdateEnvironment
@@ -98,3 +105,11 @@ export function resolveDesktopUploadConfig(
* @returns Validated channel.
*/
export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest'
/**
* Read a required nonempty deployment setting.
* @param env Packaging or upload settings.
* @param name Setting name included in missing-value diagnostics.
* @returns Trimmed setting value.
*/
export function requiredEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string
@@ -35,6 +35,17 @@ const UPDATE_ENVIRONMENTS = {
const UPDATE_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64'])
/**
* Name the fixed installer object shared by uploading and cache refresh.
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target Supported Desktop target.
* @returns {string} Fixed download filename.
*/
export function desktopLatestInstallerFilename(target) {
if (!UPDATE_TARGETS.has(target)) throw new Error(`desktop upload: unsupported target ${target}`)
const windows = target === 'win-x64'
return `dsh-latest-${windows ? 'windows' : 'macos'}-${target === 'mac-arm64' ? 'arm64' : 'x64'}.${windows ? 'exe' : 'dmg'}`
}
/**
* Resolve the update deployment, defaulting local release work to test.
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
@@ -99,7 +110,7 @@ export function desktopUpdateMetadataFilename(version, platform, channel = 'nigh
* @param {string} name - Environment variable to read.
* @returns {string} Trimmed setting.
*/
function requiredEnvironmentValue(env, name) {
export function requiredEnvironmentValue(env, name) {
const value = env[name]?.trim()
if (value === undefined || value === '') {
throw new Error(`desktop auto-update: ${name} must be set to a non-empty value`)
+6 -4
View File
@@ -75,10 +75,12 @@ export function desktopTargetBuildPaths(target, environment = process.env) {
*/
export function desktopTargetPlatform(target) {
assertSupportedTarget(target)
return {
platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'),
arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'),
}
const targets = /** @type {const} */ ({
'mac-arm64': { platform: 'darwin', arch: 'arm64' },
'mac-x64': { platform: 'darwin', arch: 'x64' },
'win-x64': { platform: 'win32', arch: 'x64' },
})
return targets[target]
}
/**
+73
View File
@@ -0,0 +1,73 @@
/** Submit one production fixed-installer cache purge to EdgeOne. */
import type { teo } from 'tencentcloud-sdk-nodejs-teo'
import type { DesktopPackageTargetName } from './package-target.ts'
import { desktopLatestInstallerFilename, requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
import { desktopTargetPlatform } from './desktop-build-paths.mjs'
/** Validated production destination and credentials; never serialize this value. */
export interface DesktopPurgeConfig {
readonly zoneId: string
readonly publicUrl: string
readonly secretId: string
readonly secretKey: string
}
/** The single SDK operation needed to submit a purge. */
export type DesktopPurgeClient = Pick<InstanceType<typeof teo.v20220901.Client>, 'CreatePurgeTask'>
/**
* Resolve production purge settings before uploading.
* @param env Target dotenv settings, including any credential-launcher replacement.
* @param target Fixed installer selected by the operator.
* @param publicUrl Uploaded object URL; omitted derives the URL for a cache-only submission.
* @returns Validated destination and credentials.
*/
export function resolveDesktopPurgeConfig(
env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string,
): DesktopPurgeConfig {
if (resolveDesktopAutoUpdateEnvironment(env) !== 'production') throw new DesktopPurgeError('desktop purge: production is required')
const { platform, arch } = desktopTargetPlatform(target)
const update = resolveDesktopUploadConfig(env, platform, arch)
const zoneId = requiredEnvironmentValue(env, 'DOWNLOAD_PROD_EO_ZONE_ID')
if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone')
return {
zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`,
secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName),
}
}
/** A purge submission failure containing only safe CLI diagnostics. */
export class DesktopPurgeError extends Error {}
function safeField(value: unknown, config: DesktopPurgeConfig): string | undefined {
if (typeof value !== 'string' || !/^[\w.-]{1,128}$/u.test(value)
|| value.includes(config.secretId) || value.includes(config.secretKey)) return undefined
return value
}
/**
* Submit once and report the accepted task ID, without querying cache completion.
* @param config Validated production destination and credentials.
* @param client Optional caller-owned SDK client; omitted lazily creates one with a 30-second request timeout.
* @returns Resolves when EO accepts the submission; this does not confirm cache completion.
* @throws DesktopPurgeError on rejection or an unconfirmed submission; never retries or changes uploaded bytes.
*/
export async function purgeDesktopInstaller(config: DesktopPurgeConfig, client?: DesktopPurgeClient): Promise<void> {
let response: Awaited<ReturnType<DesktopPurgeClient['CreatePurgeTask']>>
try {
client ??= new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({
credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '',
profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } },
})
response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
} catch (error) {
const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config)
throw new DesktopPurgeError(`desktop purge: submission not confirmed (${code ?? 'request failed'}); check EO task history and teo:CreatePurgeTask permission before submitting again.`)
}
if ((response.FailedList?.length ?? 0) > 0) {
throw new DesktopPurgeError('desktop purge: EO rejected the URL; inspect EO before submitting again.')
}
const jobId = safeField(response.JobId, config)
if (!jobId) throw new DesktopPurgeError('desktop purge: EO returned no task ID; check EO task history before submitting again.')
process.stdout.write(`desktop purge: submitted ${config.publicUrl}; task ${jobId}. Cache completion is not checked.\n`)
}
@@ -15,7 +15,7 @@ import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_PROD_EO_ZONE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
@@ -1,5 +1,6 @@
/** Validate a completed packaging attempt and route publication back to its original runner. */
import { z } from 'zod'
import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
const positive = z.number().int().positive()
@@ -12,12 +13,11 @@ const requestSchema = z.object({ source: z.object({
repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u),
workflowCommit: z.string(), sourceRef: z.string().min(1),
channel: z.enum(['nightly', 'latest']),
runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']),
runnerName: z.string(), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']),
qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']),
}).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef),
'Production source must be master or dsh-v*') })
const jobsSchema = z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable() }))
const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) }))
/** Validated values handed to the privileged publisher; no caller-selected shell fragments. */
export interface DesktopPublishRoute {
@@ -28,7 +28,7 @@ export interface DesktopPublishRoute {
readonly channel: 'nightly' | 'latest'
readonly runnerName: string
readonly runnerLabel: string
readonly target: 'mac-arm64' | 'win-x64'
readonly target: 'mac-arm64' | 'mac-x64' | 'win-x64'
readonly preset: 'test' | 'production'
}
@@ -49,8 +49,8 @@ export function resolveDesktopPublishRoute(
const diagnostic = desktopPublishQualificationError(source.qualification)
if (diagnostic) throw new Error(diagnostic)
const jobs = jobsSchema.parse(jobsValue)
const routes = routesSchema.parse(routesValue)
const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml'
const routes = desktopPublishRunnersSchema.parse(routesValue)
const workflow = desktopPackageWorkflows[source.target]
if (run.repository.full_name !== repository || source.repository !== repository || run.path !== workflow
|| source.runId !== String(run.id) || source.attempt !== String(run.run_attempt)
|| source.workflowCommit !== run.head_sha
@@ -58,7 +58,7 @@ export function resolveDesktopPublishRoute(
throw new Error('desktop publish: request does not match the successful packaging attempt')
}
const route = routes[source.runnerName]
if (!route || route.target !== source.target) throw new Error('desktop publish: original machine has no configured publishing route')
if (!route || !route.target.includes(source.target)) throw new Error('desktop publish: original machine has no configured publishing route')
if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) {
throw new Error('desktop publish: each configured machine needs its own unique runner label')
}
@@ -0,0 +1,18 @@
/** Approved packaging workflows and machine publication targets. */
import { z } from 'zod'
/** Each target is produced by its own manually dispatched workflow. */
export const desktopPackageWorkflows = {
'mac-arm64': '.github/workflows/macos-package.yml',
'mac-x64': '.github/workflows/macos-intel-package.yml',
'win-x64': '.github/workflows/windows-package.yml',
} as const
const targetSchema = z.enum(['mac-arm64', 'mac-x64', 'win-x64'])
/** Machine labels are unique; a scalar target is shorthand for a one-element target list. */
export const desktopPublishRunnersSchema = z.record(z.string(), z.object({
label: z.string().regex(/^dsh-[a-z0-9-]+$/u),
target: z.union([targetSchema, z.array(targetSchema).min(1)])
.transform(value => typeof value === 'string' ? [value] : value),
}))
+2 -1
View File
@@ -8,6 +8,7 @@ import { dump, load } from 'js-yaml'
import type { DesktopPackageTargetName } from './package-target.ts'
import {
desktopBuildRecordFilename,
desktopLatestInstallerFilename,
desktopUpdateMetadataFilename,
resolveDesktopUploadConfig,
} from './desktop-auto-update-environment.mjs'
@@ -277,7 +278,7 @@ export async function createDesktopUploadPlan(
contents: dump(published),
}
artifacts.push(channelArtifact)
const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}`
const latestFilename = desktopLatestInstallerFilename(targetName)
const latestKey = `desktop/${latestFilename}`
return {
environment: update.environment,
@@ -29,7 +29,7 @@ export function preflightDesktopPublish(repository, inputs, api) {
for (const { name } of pages.flatMap(page => page.artifacts)) {
if (!name.endsWith(`-${inputs.package_run_id}-${attempt}`)) continue
const qualification = name.startsWith('windows-x64-unsigned-') ? 'unsigned'
: name.startsWith('macos-arm64-unnotarized-debug-') ? 'unnotarized' : ''
: /^macos-(?:arm64|x64)-unnotarized-debug-/u.test(name) ? 'unnotarized' : ''
const diagnostic = desktopPublishQualificationError(qualification)
if (diagnostic) throw new Error(diagnostic)
}
+5 -2
View File
@@ -2,9 +2,12 @@
import { appendFileSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import { desktopTargetPlatform, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
import { desktopCiVersion } from './desktop-ci-version.mjs'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
const target = desktopTargetPlatform(resolveDesktopBuildTarget(process.env))
if (target.platform !== 'darwin') throw new Error('macOS CI requires a macOS target')
const appRoot = resolve(import.meta.dirname, '..')
const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8'))
const version = desktopCiVersion(process.env, product.version)
@@ -18,8 +21,8 @@ for (const [name, value] of Object.entries(environment)) {
process.stdout.write(`::add-mask::${escaped}\n`)
}
}
validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization })
validateDesktopPackageEnvironment(environment, target, { skipNotarization })
if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed'}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS ${target.arch} signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
if (skipNotarization) appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\nWARNING: signed but NOT notarized or stapled. Internal debugging only; Gatekeeper may block downloaded copies. Not qualified for distribution or update publishing.\n')
+1 -1
View File
@@ -18,7 +18,7 @@ import { desktopPublishQualificationError } from './desktop-publish-qualificatio
const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(),
sourceRef: z.string(), channel: z.enum(['nightly', 'latest']),
target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']) })
target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']) })
/**
* Check retained bytes, original machine, destination and signatures before reading any upload secret.
@@ -3,17 +3,17 @@ import { execFileSync } from 'node:child_process'
import { appendFile, readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { z } from 'zod'
import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts'
const positiveId = z.string().regex(/^[1-9]\d*$/u)
const inputsSchema = z.object({ package_run_id: positiveId, package_run_attempt: z.union([positiveId, z.literal('')]).optional() })
const runSchema = z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive(),
head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.literal('success'),
event: z.literal('workflow_dispatch'), path: z.enum(['.github/workflows/macos-package.yml', '.github/workflows/windows-package.yml']),
event: z.literal('workflow_dispatch'), path: z.enum(Object.values(desktopPackageWorkflows)),
repository: z.object({ full_name: z.string() }) })
const jobsSchema = z.object({ jobs: z.array(z.object({
name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable(),
})) })
const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) }))
/**
* Read GitHub evidence for one explicit attempt, or the latest attempt without falling back after failure.
@@ -37,10 +37,11 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r
const packages = jobs.filter(job => job.name === 'package' && job.conclusion === 'success' && job.runner_name)
if (packages.length !== 1) throw new Error('desktop publish: expected one successful package job')
const runnerName = packages[0]!.runner_name!
const routes = routesSchema.parse(routesValue)
const routes = desktopPublishRunnersSchema.parse(routesValue)
const route = routes[runnerName]
const target = run.path.endsWith('/macos-package.yml') ? 'mac-arm64' : 'win-x64'
if (!route || route.target !== target) throw new Error('desktop publish: original machine has no configured publishing route')
const target = (Object.keys(desktopPackageWorkflows) as Array<keyof typeof desktopPackageWorkflows>)
.find(name => desktopPackageWorkflows[name] === run.path)!
if (!route || !route.target.includes(target)) throw new Error('desktop publish: original machine has no configured publishing route')
if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) {
throw new Error('desktop publish: each configured machine needs its own unique runner label')
}
@@ -17,6 +17,8 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly<Record<string, DesktopRuntimePatc
'app-builder-lib@26.17.0': { scope: 'workspace-only', reason: 'DMG format validation executes on the build host.' },
'exceljs@4.4.0': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' },
'lexical@0.49.0': { scope: 'workspace-only', reason: 'The patched editor is embedded in the client bundle.' },
'mdast-util-gfm-autolink-literal@2.0.1': { scope: 'workspace-only', reason: 'The patched Markdown parser is embedded in the client bundle.' },
'micromark-extension-gfm-autolink-literal@2.1.0': { scope: 'workspace-only', reason: 'The patched Markdown parser is embedded in the client bundle.' },
'node-pty@1.2.0-beta.15': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' },
}
+73 -25
View File
@@ -1,31 +1,28 @@
/** Upload validated Desktop update artifacts or a fixed installer to Tencent COS. */
/** Upload Desktop artifacts to COS and refresh production fixed downloads through EdgeOne. */
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import type { DesktopPackageTargetName } from './package-target.ts'
import { createDesktopCos } from './desktop-cos.ts'
import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
import { requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
import { desktopTargetPlatform } from './desktop-build-paths.mjs'
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
import { createDesktopUploadPlan, type DesktopUploadPlan } from './desktop-upload-plan.ts'
import { desktopReleaseTag, tagDesktopRelease } from './desktop-release-tag.ts'
import { uploadDesktopRelease } from './desktop-upload-run.ts'
import { DesktopPurgeError, purgeDesktopInstaller, resolveDesktopPurgeConfig } from './desktop-edgeone.ts'
const SUPPORTED_TARGETS = new Set<DesktopPackageTargetName>(['mac-arm64', 'mac-x64', 'win-x64'])
function targetName(value: string): DesktopPackageTargetName {
if (!SUPPORTED_TARGETS.has(value as DesktopPackageTargetName)) {
throw new Error(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`)
throw new DesktopUploadUsageError(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`)
}
return value as DesktopPackageTargetName
}
function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): string {
const value = environment[name]?.trim()
if (value === undefined || value === '') {
throw new Error(`desktop upload: ${name} must be set to a non-empty value`)
}
return value
}
/** Invalid command arguments or release configuration, reported before network writes. */
class DesktopUploadUsageError extends Error {}
/**
* Use the credential launcher's selected deployment only when it matches the packaged release destination.
@@ -41,15 +38,14 @@ export function resolveCredentialUploadEnvironment(
selected: 'test' | 'production', bucket: string,
target: DesktopPackageTargetName,
): NodeJS.ProcessEnv {
const platform = target === 'win-x64' ? 'win32' : 'darwin'
const arch = target === 'mac-arm64' ? 'arm64' : 'x64'
const { platform, arch } = desktopTargetPlatform(target)
const destination = resolveDesktopUploadConfig(fileEnvironment, platform, arch)
if (destination.environment !== selected || destination.bucket !== bucket) {
throw new Error('desktop upload: credential launcher deployment or bucket differs from the packaged release destination')
throw new DesktopUploadUsageError('desktop upload: credential launcher deployment or bucket differs from the packaged release destination')
}
if (injectedEnvironment.DSH_DESKTOP_AUTO_UPDATE_ENV !== selected
|| injectedEnvironment[`${selected === 'test' ? 'DOWNLOAD_TEST' : 'DOWNLOAD_PROD'}_COS_BUCKET`] !== bucket) {
throw new Error('desktop upload: credential launcher environment differs from its explicit arguments')
throw new DesktopUploadUsageError('desktop upload: credential launcher environment differs from its explicit arguments')
}
return {
...fileEnvironment,
@@ -58,40 +54,74 @@ export function resolveCredentialUploadEnvironment(
}
}
function purgeConfig(environment: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string) {
try {
return resolveDesktopPurgeConfig(environment, target, publicUrl)
} catch (error) {
if (error instanceof DesktopPurgeError) throw error
throw new DesktopUploadUsageError('Invalid production purge settings; check DOWNLOAD_PROD_COS_BUCKET, DOWNLOAD_PROD_COS_SECRET_ID, DOWNLOAD_PROD_COS_SECRET_KEY and DOWNLOAD_PROD_EO_ZONE_ID in the target dotenv.')
}
}
/**
* Upload the selected target and record a release tag only for production update uploads.
* Upload the selected target, refresh production fixed downloads, or submit only their cache purge.
* @param args Target and upload options from the command line.
* @returns Resolves after upload and any release-tag attempt complete.
* @returns Resolves after upload, any required EO submission, and any release-tag attempt complete.
* @throws DesktopPurgeError when purge submission fails; a completed upload is not repeated or rolled back.
*/
export async function uploadDesktopTarget(args: string[]): Promise<void> {
const { positionals, values } = parseArgs({ args, allowPositionals: true, options: {
latest: { type: 'boolean' },
'purge-only': { type: 'boolean' },
'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' },
} })
const target = positionals[0]
if (target === undefined || positionals.length !== 1) {
throw new Error('desktop upload: expected exactly one target')
throw new DesktopUploadUsageError('desktop upload: expected exactly one target')
}
const name = targetName(target)
const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin')
if (values['purge-only'] && !values.latest) {
throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest')
}
let fileEnvironment: NodeJS.ProcessEnv
try {
fileEnvironment = loadDesktopPackageEnvironment(desktopTargetPlatform(name).platform)
} catch (error) {
throw new DesktopUploadUsageError('Cannot load target dotenv; check the target dotenv file and release settings.')
}
const launcher = values['credential-launcher'] === true
if (launcher ? values.environment === undefined || values.bucket === undefined
: values.environment !== undefined || values.bucket !== undefined) {
throw new Error('desktop upload: credential launcher requires an explicit environment and bucket')
throw new DesktopUploadUsageError('desktop upload: credential launcher requires an explicit environment and bucket')
}
if (values.environment !== undefined && values.environment !== 'test' && values.environment !== 'production') {
throw new Error('desktop upload: credential launcher environment must be test or production')
throw new DesktopUploadUsageError('desktop upload: credential launcher environment must be test or production')
}
const environment = launcher
? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name)
: fileEnvironment
try {
resolveDesktopAutoUpdateEnvironment(environment)
} catch (error) {
throw new DesktopUploadUsageError('DSH_DESKTOP_AUTO_UPDATE_ENV must be "test" or "production".')
}
const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records')
if (values['purge-only']) {
await purgeDesktopInstaller(purgeConfig(environment, name))
return
}
const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true })
const purge = plan.environment === 'production' && values.latest ? purgeConfig(environment, name, plan.publicUrl) : undefined
const cos = createDesktopCos({
secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName),
secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName),
})
process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)
await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records'))
await uploadDesktopRelease(plan, cos, recordsRoot)
if (purge !== undefined) {
process.stdout.write('desktop upload: installer uploaded; submitting cache purge.\n')
await purgeDesktopInstaller(purge)
}
if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan)
}
@@ -121,9 +151,27 @@ function recordProductionRelease(plan: DesktopUploadPlan): void {
process.stdout.write(`desktop upload: ${result.tag} ${result.status === 'created' ? 'records' : 'already recorded'} ${plan.commit}\n`)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
uploadDesktopTarget(process.argv.slice(2)).catch(() => {
process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n')
/**
* Report command failures without exposing raw storage or SDK exceptions.
* @param args Target and options forwarded by the package command or credential launcher.
* @returns Resolves after reporting any failure and setting the process exit status.
*/
export async function runDesktopUploadCommand(args: string[]): Promise<void> {
try {
await uploadDesktopTarget(args)
} catch (error) {
const usage = error instanceof DesktopUploadUsageError
|| (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS_'))
const message = error instanceof DesktopPurgeError ? error.message
: usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts <target> [--latest [--purge-only]]; targets: mac-arm64, mac-x64, win-x64.`
: 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.'
// The DPAPI launcher suppresses raw stderr; these diagnostics contain no SDK details.
const output = args.includes('--credential-launcher') ? process.stdout : process.stderr
output.write(`${message}\n`)
process.exitCode = 1
})
}
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
void runDesktopUploadCommand(process.argv.slice(2))
}
@@ -18,6 +18,8 @@ COS bucket for an explicit upload. Not needed for the local credential check.
Publish only the installer at its fixed download URL. Requires Upload.
.PARAMETER Upload
Authorize the existing target upload entry. Omit to check credentials locally.
.PARAMETER PurgeOnly
Submit a purge for the production fixed download without uploading. Requires Upload and Latest.
#>
[CmdletBinding(DefaultParameterSetName = 'Check')]
param(
@@ -36,7 +38,9 @@ param(
[Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
[switch]$Upload,
[Parameter(ParameterSetName = 'Publish')]
[switch]$Latest
[switch]$Latest,
[Parameter(ParameterSetName = 'Publish')]
[switch]$PurgeOnly
)
$ErrorActionPreference = 'Stop'
@@ -49,6 +53,11 @@ $credentials = $null
$startInfo = $null
$stage = 'decrypt-file'
if ($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) {
Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production.'
exit 1
}
try {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
throw 'Windows DPAPI is required.'
@@ -92,7 +101,12 @@ try {
$startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket
$startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket"
if ($Latest) { $startInfo.Arguments += " --latest" }
Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
if ($PurgeOnly) { $startInfo.Arguments += " --purge-only" }
if ($PurgeOnly) {
Write-Output "desktop credentials: submitting a cache purge for $Target in $Environment; no installer upload."
} else {
Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
}
} else {
$probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)"
$startInfo.Arguments = "-e `"$probe`""
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { teo } from 'tencentcloud-sdk-nodejs-teo'
import { purgeDesktopInstaller, resolveDesktopPurgeConfig } from '../scripts/desktop-edgeone.ts'
const environment = {
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket',
DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-secret-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-secret-key',
DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture',
}
const config = resolveDesktopPurgeConfig(environment, 'mac-arm64')
afterEach(() => vi.restoreAllMocks())
describe('production EdgeOne configuration', () => {
it.each([
[{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'test' }, /production is required/u],
[{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'prod' }, /must be "test" or "production"/u],
[{ DOWNLOAD_PROD_EO_ZONE_ID: '' }, /DOWNLOAD_PROD_EO_ZONE_ID/u],
[{ DOWNLOAD_PROD_EO_ZONE_ID: 'invalid' }, /must name one EO zone/u],
[{ DOWNLOAD_PROD_COS_SECRET_KEY: '' }, /DOWNLOAD_PROD_COS_SECRET_KEY/u],
] as const)('rejects invalid settings %j', (patch, message) => {
expect(() => resolveDesktopPurgeConfig({ ...environment, ...patch }, 'mac-arm64')).toThrow(message)
})
it('uses the upload plan URL verbatim and derives only cache-only destinations', () => {
const url = 'https://download.deepseek.com/changed/installer.dmg'
expect(resolveDesktopPurgeConfig(environment, 'mac-arm64', url).publicUrl).toBe(url)
expect(resolveDesktopPurgeConfig(environment, 'win-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe')
expect(resolveDesktopPurgeConfig(environment, 'mac-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg')
})
})
describe('EdgeOne purge submission', () => {
it('submits once with the pinned SDK and reports acceptance without querying completion', async () => {
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask')
.mockImplementation(async function (this: InstanceType<typeof teo.v20220901.Client>) {
expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 30 })
return { JobId: 'job-1' }
})
const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('must not query'))
await purgeDesktopInstaller(config)
expect(submit).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
expect(query).not.toHaveBeenCalled()
expect(output).toHaveBeenCalledExactlyOnceWith(
`desktop purge: submitted ${config.publicUrl}; task job-1. Cache completion is not checked.\n`,
)
})
it('reports a rejected URL without printing the raw response or claiming acceptance', async () => {
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
const submit = vi.fn().mockResolvedValue({ JobId: 'job-1', FailedList: [{ Targets: [config.publicUrl], Reason: config.secretKey }] })
await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow(
'desktop purge: EO rejected the URL; inspect EO before submitting again.',
)
expect(submit).toHaveBeenCalledOnce()
expect(output).not.toHaveBeenCalled()
})
it.each(['ECONNRESET', 'UnauthorizedOperation', undefined, config.secretKey])('does not retry or expose SDK errors (%s)', async (code) => {
const submit = vi.fn().mockRejectedValue(Object.assign(new Error(config.secretKey), { code }))
await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow(
`desktop purge: submission not confirmed (${code === undefined || code === config.secretKey ? 'request failed' : code}); check EO task history and teo:CreatePurgeTask permission before submitting again.`,
)
expect(submit).toHaveBeenCalledOnce()
})
it.each([undefined, 'unsafe value', config.secretId])('reports an unconfirmed submission without a usable task ID (%s)', async (jobId) => {
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
const submit = vi.fn().mockResolvedValue({ JobId: jobId })
await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow(
'desktop purge: EO returned no task ID; check EO task history before submitting again.',
)
expect(submit).toHaveBeenCalledOnce()
expect(output).not.toHaveBeenCalled()
})
})
@@ -121,3 +121,17 @@ it('resolves an old locally retained package with publishRequested=false without
await expect(resolveRetainedDesktopPublication(env, api)).rejects.toThrow()
} finally { await rm(root, { recursive: true, force: true }) }
})
it('routes both Mac architectures to one machine without authorizing an unlisted target', () => {
const f = fixture()
const routes = { mini: { label: 'dsh-mini', target: ['mac-arm64', 'mac-x64'] } }
for (const path of ['macos-package.yml', 'macos-intel-package.yml']) {
f.run.path = `.github/workflows/${path}`
expect(selectDesktopPackage('owner/repo', { package_run_id: '42' }, routes, f.api))
.toMatchObject({ runnerName: 'mini', runnerLabel: 'dsh-mini' })
}
expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, f.api)).toThrow(/no configured/u)
for (const target of [[], ['mac-x64', 'linux-x64']]) {
expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, { mini: { label: 'dsh-mini', target } }, f.api)).toThrow()
}
})
@@ -2,6 +2,7 @@ import { expect, it } from 'vitest'
import { preflightDesktopPublish } from '../scripts/preflight-desktop-publish.mjs'
it.each([
['macos-x64-unnotarized-debug-1.0.0-42-2', '跳过了公证'],
['macos-arm64-unnotarized-debug-1.0.0-42-2', '跳过了公证'],
['windows-x64-unsigned-1.0.0-42-2', '未签名'],
])('rejects %s from paginated artifact metadata with rebuilding instructions', (name, reason) => {
@@ -75,3 +75,16 @@ it('allows repository branch packages for test but rejects them for production',
f.source.preset = 'production'
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow(/Production source/u)
})
it('matches Intel artifacts to the Intel workflow on a machine serving both architectures', () => {
const f = fixture()
f.source.target = 'mac-x64'
const routes = { mini: { label: 'dsh-mini', target: ['mac-arm64', 'mac-x64'] } }
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)).toThrow(/does not match/u)
f.run.path = '.github/workflows/macos-intel-package.yml'
expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes))
.toMatchObject({ target: 'mac-x64', runnerLabel: 'dsh-mini' })
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow(/no configured/u)
f.source.target = 'mac-arm64'
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)).toThrow(/does not match/u)
})
@@ -60,7 +60,7 @@ it.skipIf(process.platform !== 'darwin')('prepares private machine directories w
}
})
it('records debugging artifact paths only for an explicit CI selection without notarization credentials', async () => {
it.each(['arm64', 'x64'])('records %s debugging artifact paths only for an explicit CI selection without notarization credentials', async (arch) => {
const root = await mkdtemp(join(tmpdir(), 'macos-ci-settings-'))
try {
await mkdir(join(root, 'presets'))
@@ -81,6 +81,7 @@ it('records debugging artifact paths only for an explicit CI selection without n
const run = (skip: string) => spawnSync(process.execPath,
[join(import.meta.dirname, '../scripts/prepare-macos-ci.mjs')], { encoding: 'utf8', env: {
...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test',
DSH_DESKTOP_TARGET_PLATFORM: 'darwin', DSH_DESKTOP_TARGET_ARCH: arch,
PACKAGE_SKIP_NOTARIZATION: skip, PACKAGE_BUILD_VERSION: 'auto',
GITHUB_RUN_NUMBER: '1', GITHUB_RUN_ATTEMPT: '1', GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary,
} })
@@ -91,10 +92,13 @@ it('records debugging artifact paths only for an explicit CI selection without n
expect(invalidSelection.status).toBe(1)
expect(invalidSelection.stderr).toContain('Invalid skip notarization selection')
const debugging = run('true')
expect(debugging.error).toBeUndefined()
expect(debugging.signal).toBeNull()
expect(debugging.stderr).toBe('')
expect(debugging.status).toBe(0)
expect(await readFile(output, 'utf8')).toContain('artifacts=unnotarized-artifacts\nmode=unnotarized-debug\n')
expect(await readFile(summary, 'utf8')).toContain('Internal debugging only')
expect(await readFile(summary, 'utf8')).toContain(`macOS ${arch} signed installer`)
} finally {
await rm(root, { recursive: true, force: true })
}
@@ -43,7 +43,7 @@ it('classifies the current workspace and shares only runtime patch bytes', () =>
const before = readFileSync(join(repository, 'pnpm-lock.yaml'))
const plan = prepareRuntimePatches(project, repository)
expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.3.0', 'node-pty@1.2.0-beta.15'])
expect(plan.workspaceOnly).toHaveLength(8)
expect(plan.workspaceOnly).toHaveLength(10)
expect(plan.workspaceOnly).toContain('@botiverse/oar')
expect(plan.workspaceOnly).toContain('lexical')
const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record<string, string> }
+63
View File
@@ -0,0 +1,63 @@
import { execFile } from 'node:child_process'
import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { z } from 'zod'
import { afterEach, expect, it } from 'vitest'
const execute = promisify(execFile)
const root = fileURLToPath(new URL('../../..', import.meta.url))
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
it('forwards the documented pnpm purge command through both package scripts to the real argument parser', async () => {
const directory = await mkdtemp(join(tmpdir(), 'desktop-purge-command-'))
roots.push(directory)
const scripts = join(directory, 'apps/desktop/scripts')
await mkdir(scripts, { recursive: true })
const manifest = z.object({ name: z.string(), scripts: z.object({ 'purge:latest': z.string() }) })
const repository = manifest.parse(JSON.parse(await readFile(join(root, 'package.json'), 'utf8')))
const desktop = manifest.parse(JSON.parse(await readFile(join(root, 'apps/desktop/package.json'), 'utf8')))
await writeFile(join(directory, 'package.json'), JSON.stringify({ private: true, type: 'module', scripts: { 'purge:latest': repository.scripts['purge:latest'] } }))
await writeFile(join(directory, 'pnpm-workspace.yaml'), 'packages:\n - apps/desktop\n')
await writeFile(join(directory, 'apps/desktop/package.json'), JSON.stringify({ name: desktop.name, type: 'module', scripts: { 'purge:latest': desktop.scripts['purge:latest'] } }))
await mkdir(join(directory, 'node_modules'))
for (const dependency of ['.bin', 'pnpm', 'tsx', 'semver']) {
await symlink(join(root, 'node_modules', dependency), join(directory, 'node_modules', dependency), 'junction')
}
await copyFile(join(root, 'apps/desktop/scripts/upload-target.ts'), join(scripts, 'upload-target.ts'))
for (const filename of ['desktop-auto-update-environment.mjs', 'desktop-build-paths.mjs']) {
await copyFile(join(root, 'apps/desktop/scripts', filename), join(scripts, filename))
}
// Only command forwarding and parsing are under test; all external work fails closed.
const stubs = {
'desktop-package-environment.mjs': "export const loadDesktopPackageEnvironment = () => ({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' })",
'desktop-cos.ts': "export const createDesktopCos = () => { throw new Error('unexpected COS') }",
'desktop-upload-plan.ts': "export const createDesktopUploadPlan = () => { throw new Error('unexpected installer read') }",
'desktop-upload-run.ts': "export const uploadDesktopRelease = () => { throw new Error('unexpected upload') }",
'desktop-release-tag.ts': "export const desktopReleaseTag = () => { throw new Error('unexpected tag') }; export const tagDesktopRelease = desktopReleaseTag",
'desktop-edgeone.ts': `
import assert from 'node:assert/strict'
export class DesktopPurgeError extends Error {}
export const resolveDesktopPurgeConfig = (_env, target) => ({ target })
export const purgeDesktopInstaller = async (config) => {
assert.equal(config.target, 'mac-arm64')
console.log('purge-submit: mac-arm64')
}
`,
}
await Promise.all(Object.entries(stubs).map(([name, contents]) => writeFile(join(scripts, name), contents)))
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
!/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_|^DOWNLOAD_|^PACKAGE_/iu.test(name)))
const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64']
const result = await execute(process.execPath, args, { cwd: directory, env, timeout: 30_000 }).catch((error: unknown) => {
expect(error).not.toMatchObject({ killed: true })
throw error
})
expect(result.stdout).toContain('purge-submit: mac-arm64')
})
+4 -4
View File
@@ -10,7 +10,7 @@ const roots: string[] = []
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
it.each([
['mac-arm64', 'notarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'],
['mac-arm64', 'notarized'], ['mac-x64', 'notarized'], ['mac-x64', 'unnotarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'],
] as const)('retains the complete %s %s release without signing or upload credentials', async (target, qualification) => {
const root = await mkdtemp(join(tmpdir(), 'retain-ci-'))
roots.push(root)
@@ -23,13 +23,13 @@ it.each([
await writeFile(join(appRoot, 'package.json'), '{"version":"1.0.0"}')
await writeFile(join(config, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${'a'.repeat(32)}\n`)
const names = debug ? (target === 'win-x64' ? [`deepseek-harness-1.0.0-win-x64-${qualification}.exe`]
: [`deepseek-harness-1.0.0-mac-arm64-${qualification}.dmg`, `deepseek-harness-1.0.0-mac-arm64-${qualification}.zip`]) : target === 'win-x64'
: [`deepseek-harness-1.0.0-${target}-${qualification}.dmg`, `deepseek-harness-1.0.0-${target}-${qualification}.zip`]) : target === 'win-x64'
? ['deepseek-harness-1.0.0-win-x64.exe', 'deepseek-harness-1.0.0-win-x64.exe.blockmap', 'win-x64-release.json', 'nightly.yml']
: ['deepseek-harness-1.0.0-mac-arm64.dmg', 'deepseek-harness-1.0.0-mac-arm64.zip', 'deepseek-harness-1.0.0-mac-arm64.zip.blockmap', 'mac-arm64-release.json', 'nightly-mac.yml']
: [`deepseek-harness-1.0.0-${target}.dmg`, `deepseek-harness-1.0.0-${target}.zip`, `deepseek-harness-1.0.0-${target}.zip.blockmap`, `${target}-release.json`, 'nightly-mac.yml']
await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture')))
const env = {
DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin',
DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0',
DSH_DESKTOP_TARGET_ARCH: target === 'mac-arm64' ? 'arm64' : 'x64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0',
PACKAGE_SIGNING: qualification === 'unsigned' ? 'unsigned' : 'signed', PACKAGE_SKIP_NOTARIZATION: qualification === 'unnotarized' ? 'true' : 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2',
GITHUB_STEP_SUMMARY: join(root, 'summary.md'),
GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated',
+92 -3
View File
@@ -1,9 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DesktopPurgeError, purgeDesktopInstaller } from '../scripts/desktop-edgeone.ts'
import { createDesktopUploadPlan, type DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
import { tagDesktopRelease } from '../scripts/desktop-release-tag.ts'
import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts'
import { uploadDesktopTarget } from '../scripts/upload-target.ts'
import { runDesktopUploadCommand, uploadDesktopTarget } from '../scripts/upload-target.ts'
vi.mock('../scripts/desktop-edgeone.ts', async importOriginal => ({
...await importOriginal<typeof import('../scripts/desktop-edgeone.ts')>(),
purgeDesktopInstaller: vi.fn(),
}))
vi.mock('../scripts/desktop-package-environment.mjs', () => ({ loadDesktopPackageEnvironment: vi.fn() }))
vi.mock('../scripts/desktop-upload-plan.ts', () => ({ createDesktopUploadPlan: vi.fn() }))
@@ -27,7 +33,7 @@ describe('desktop upload command', () => {
{ environment: 'test', latest: false, tagged: false },
] as const)('uploads $environment latest=$latest with tagged=$tagged', async ({ environment, latest, tagged }) => {
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
const credentials = { DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' }
const credentials = { ...productionEnvironment, DSH_DESKTOP_AUTO_UPDATE_ENV: environment, DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' }
const plan: DesktopUploadPlan = {
environment, target: 'win-x64', version: '1.2.3-alpha.4',
publicUrl: 'https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe',
@@ -44,6 +50,15 @@ describe('desktop upload command', () => {
expect(createDesktopUploadPlan).toHaveBeenCalledWith('win-x64', { environment: credentials, latest })
expect(uploadDesktopRelease).toHaveBeenCalledOnce()
expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan)
if (environment === 'production' && latest) {
expect(purgeDesktopInstaller).toHaveBeenCalledWith(
expect.objectContaining({ publicUrl: plan.publicUrl }),
)
expect(vi.mocked(purgeDesktopInstaller).mock.invocationCallOrder[0])
.toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!)
} else {
expect(purgeDesktopInstaller).not.toHaveBeenCalled()
}
if (tagged) {
expect(tagDesktopRelease).toHaveBeenCalledWith(expect.objectContaining({ version: plan.version, commit: plan.commit }))
expect(vi.mocked(tagDesktopRelease).mock.invocationCallOrder[0])
@@ -54,3 +69,77 @@ describe('desktop upload command', () => {
}
})
})
const productionEnvironment = {
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket',
DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-key', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture',
}
describe('cache-only submission command', () => {
beforeEach(() => {
vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(productionEnvironment)
})
it('submits a purge without reading or uploading an installer', async () => {
await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64'])
expect(createDesktopUploadPlan).not.toHaveBeenCalled()
expect(uploadDesktopRelease).not.toHaveBeenCalled()
expect(tagDesktopRelease).not.toHaveBeenCalled()
expect(purgeDesktopInstaller).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
publicUrl: 'https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg',
}))
})
it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--latest', '--purge-only', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => {
await expect(uploadDesktopTarget(args)).rejects.toThrow()
expect(uploadDesktopRelease).not.toHaveBeenCalled()
expect(purgeDesktopInstaller).not.toHaveBeenCalled()
})
it('reports a completed upload when refresh fails, without tagging or repeating the upload', async () => {
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
vi.mocked(createDesktopUploadPlan).mockResolvedValue({
environment: 'production', target: 'mac-arm64', version: '1.2.3', publicUrl: 'https://example.com/new-prefix/installer.dmg',
bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', artifacts: [],
})
vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: submission not confirmed'))
await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow('desktop purge: submission not confirmed')
expect(output).toHaveBeenCalledWith('desktop upload: installer uploaded; submitting cache purge.\n')
expect(uploadDesktopRelease).toHaveBeenCalledOnce()
expect(purgeDesktopInstaller).toHaveBeenCalledOnce()
expect(tagDesktopRelease).not.toHaveBeenCalled()
})
it.each([false, true])('reports safe purge diagnostics to the launcher-visible stream: launcher=%s', async (launcher) => {
const previousExitCode = process.exitCode
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
vi.stubEnv('DSH_DESKTOP_AUTO_UPDATE_ENV', 'production')
vi.stubEnv('DOWNLOAD_PROD_COS_BUCKET', 'fixture-bucket')
vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_ID', 'injected-id')
vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_KEY', 'injected-key')
try {
vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: safe recovery message'))
await runDesktopUploadCommand(['mac-arm64', '--latest', '--purge-only', ...launcher ? ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'] : []])
expect(launcher ? stdout : stderr).toHaveBeenCalledExactlyOnceWith('desktop purge: safe recovery message\n')
expect(launcher ? stderr : stdout).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' }))
} finally {
process.exitCode = previousExitCode
vi.unstubAllEnvs()
}
})
it('shows usage without referring to a nonexistent record for invalid arguments', async () => {
const previousExitCode = process.exitCode
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
await runDesktopUploadCommand([])
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Usage:'))
expect(stderr).not.toHaveBeenCalledWith(expect.stringContaining('record directory'))
} finally {
process.exitCode = previousExitCode
}
})
})
@@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1',
const roots: string[] = []
const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure', deployment = 'production') {
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge' | 'invalid-purge', deployment = 'production') {
const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-'))
roots.push(root)
const credentialPath = join(root, 'credential.clixml')
let entry = launcher
let uploadArguments = ''
if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure') {
if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge' || mode === 'invalid-purge') {
const scripts = join(root, 'apps/desktop/scripts')
await mkdir(scripts, { recursive: true })
entry = join(scripts, 'upload-with-credentials.ps1')
@@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload
assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined)
assert.equal(process.env.NODE_OPTIONS, undefined)
assert.equal(process.argv[2], 'win-x64')
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-latest' ? ", '--latest'" : ''}])
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge' ? ", '--latest', '--purge-only'" : mode === 'upload-latest' ? ", '--latest'" : ''}])
console.log('fixture-id fixture-secret')
console.error('private service details fixture-secret')
process.exit(${mode === 'upload-failure' ? 17 : 0})
`)
uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-latest' ? ' -Latest' : ''}`
uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge' ? ' -Latest -PurgeOnly' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}`
}
const setup = mode === 'missing' ? '' : `
[pscustomobject]@{
@@ -134,7 +134,14 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () =
expect(result.output).not.toContain('child environment verified')
})
it.each(['upload', 'upload-latest', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
it('reports invalid purge arguments before decrypting credentials', async () => {
const result = await check('invalid-purge')
expect(result.code).toBe(1)
expect(result.output).toContain('invalid arguments; PurgeOnly requires Latest and production')
expect(result.output).not.toContain('stage=decrypt-file')
})
it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
const result = await check(mode)
expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0)
expect(result.output).toContain('[REDACTED] [REDACTED]')
+2 -1
View File
@@ -72,7 +72,8 @@
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-worktree": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*"
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*"
},
"dependencies": {
"@deepseek-ai/dsh-message-quotes": "workspace:*"
@@ -0,0 +1,50 @@
/** A bare URL in a settled CJK reply ends before the following full-width punctuation. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, compareOrRefreshGolden,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/autolink-cjk-punctuation', import.meta.url))
const MODE = webSnapshotMode()
const URL_TEXT = 'http://127.0.0.1:8790/'
describe('web e2e: bare URL before CJK punctuation', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, await readFile(join(SNAPSHOT_DIR, 'session.v4.jsonl'), 'utf8'), 'autolink-cjk-punctuation')
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"]').nth(1).click()
await page.getByRole('link', { name: URL_TEXT, exact: true }).waitFor()
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('links only the URL and renders the following sentence as text', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-autolink-cjk-punctuation'))
const link = page.getByRole('link', { name: URL_TEXT, exact: true })
expect(await link.getAttribute('href')).toBe(URL_TEXT)
const prose = await link.locator('..').ariaSnapshot()
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'reply.expected.md'), prose, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.v4.jsonl', 'reply.expected.md'])
})
})
@@ -0,0 +1,20 @@
- img "变更请求状态:需要处理"
- text: "Add the pull request panel #7"
- button "在浏览器中打开"
- button "全部 2 个文件"
- text: +4 −2
- button "刷新变更"
- button "全部折叠"
- button "左右对比" [pressed]
- button "自动换行" [pressed]
- button "收起 panel.ts" [expanded]
- text: src/panel.ts +2 −2
- button "panel.ts 的更多操作"
- text: 1 export const n1 = 1 1 export const n1 = 1 2 export const n2 = 2 2 export const n2 = 'two' 3 export const n3 = 3 3 export const n3 = 3 4 export const n4 = 4 4 export const n4 = 4 5 export const n5 = 5 5 export const n5 = 5
- button "6 行未变更"
- text: 12 export const n12 = 12 12 export const n12 = 12 13 export const n13 = 13 13 export const n13 = 13 14 export const n14 = 14 14 export const n14 = 14 15 export const n15 = 15 15 export const n15 = 'fifteen' 16 export const n16 = 16 16 export const n16 = 16 17 export const n17 = 17 17 export const n17 = 17 18 export const n18 = 18 18 export const n18 = 18
- button "其余未变更的行"
- button "收起 panel.md" [expanded]
- text: docs/panel.md 新文件 +2 −0
- button "panel.md 的更多操作"
- text: "1 + # Panel 2 + Shows the change requests of a Session."
@@ -0,0 +1,7 @@
- menu:
- text: "将 #9 合并到 main"
- menuitem "压缩合并 把全部提交压缩成一个提交"
- menuitem "创建合并提交 保留全部提交,并新增一个合并提交"
- menuitem "变基合并 把提交逐个变基到 main"
- separator
- menuitem "确认合并"
@@ -0,0 +1,7 @@
- menu:
- text: "将 #9 合并到 main"
- menuitem "压缩合并 把全部提交压缩成一个提交"
- menuitem "创建合并提交 保留全部提交,并新增一个合并提交"
- menuitem "变基合并 把提交逐个变基到 main"
- separator
- menuitem "提交已更新,请重新检查后再打开合并菜单" [disabled]
@@ -0,0 +1,17 @@
- text: dsh-{{worktree}} 工作树 · 基于 release
- button "复制工作树路径"
- heading "本会话的变更请求 · 1" [level=3]
- button "刷新状态"
- list:
- listitem:
- img "变更请求状态:需要处理"
- link "Add the pull request panel":
- /url: https://github.com/octo/demo/pull/7
- text: "#7 feat → main"
- button "在浏览器中打开"
- button "复制变更请求链接"
- 'button "从本会话移除 #7"'
- 'button "收起 #7" [expanded]'
- button "检查 1 项失败 · 1 项通过"
- text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突
- 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14
@@ -0,0 +1,12 @@
- listitem:
- img "变更请求状态:需要处理"
- link "Add the pull request panel":
- /url: https://github.com/octo/demo/pull/7
- text: "#7 feat → main"
- button "在浏览器中打开"
- button "复制变更请求链接"
- 'button "从本会话移除 #7"'
- 'button "收起 #7" [expanded]'
- button "检查 1 项失败 · 1 项通过"
- text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突
- 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14
@@ -0,0 +1 @@
- text: "dsh-{{worktree}} 工作树 · 基于 release #7 Add the pull request panel 需要处理 · 检查失败"
@@ -42,6 +42,11 @@
- text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 变更请求与工作树": 变更请求与工作树
- text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。
- status
- switch "启用 变更请求与工作树"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -42,6 +42,11 @@
- text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 变更请求与工作树": 变更请求与工作树
- text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。
- status
- switch "启用 变更请求与工作树"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -42,6 +42,11 @@
- text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 变更请求与工作树": 变更请求与工作树
- text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。
- status
- switch "启用 变更请求与工作树"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -6,7 +6,7 @@ Focus at 500ms:
- tooltip "刷新"
failure: pending
- button "刷新" [disabled]
{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
failure: settled
- button "刷新"
{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
+641
View File
@@ -0,0 +1,641 @@
/** Worktree Sessions and the change request panel through the real optional bundle, a Git checkout, and a scripted GitHub CLI. */
import { execFile } from 'node:child_process'
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import type {} from '@deepseek-ai/dsh-experimental-forge-session'
import { pluginRecordOf, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
import { chromium, type Browser, type Page } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, readPersistedEvents, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE } from './support.ts'
const BUNDLE = fileURLToPath(new URL('../../../packages/experimental/forge-bundle', import.meta.url))
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v4.jsonl', import.meta.url))
const EXPECTED = fileURLToPath(new URL('./expected/forge-panel', import.meta.url))
const MODE = webSnapshotMode()
const PULL = 'https://github.com/octo/demo/pull/7'
/** A draft change request that the double lets the panel mark ready and merge. */
const DRAFT = 'https://github.com/octo/demo/pull/9'
/** Message sent after the worktree is removed, which waits until the worktree is restored. */
const FOLLOW_UP = 'Summarize the failed check'
/** Reply to {@link FOLLOW_UP}, a model call appended after the borrowed scenario's single one. */
const FOLLOW_UP_REPLY = 'RESTORED'
const run = promisify(execFile)
/**
* GitHub CLI double: signed in to github.com, with octo/demo allowing every
* merge method and the viewer's usual one squash. GraphQL serves pull request
* 7 with one failed and one passed check run, one unresolved thread, and a
* required review, and pull request 9, an approved draft whose checks passed.
* `gh pr ready 9` and the REST merge of pull request 9 change what later
* queries report, through `state.json`. `gh pr diff N` prints `pr-N.diff`, or
* fails with `pr-N.refused` on standard error when that file exists, and the raw
* contents API serves `contents.json`, keyed by `path@commit`. Every invocation
* is appended to `calls.jsonl`.
*/
const GH = `#!/usr/bin/env node
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const args = process.argv.slice(2)
const here = dirname(fileURLToPath(import.meta.url))
appendFileSync(join(here, 'calls.jsonl'), JSON.stringify(args) + '\\n')
const statePath = join(here, 'state.json')
const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : { ready: false, mergedAt: null }
const save = () => { writeFileSync(statePath, JSON.stringify(state)) }
if (args[0] === 'pr' && args[1] === 'view' && args[2] === '7') {
const fields = args[args.indexOf('--json') + 1]
process.stdout.write(JSON.stringify(fields === 'url'
? { url: 'https://github.com/octo/demo/pull/7' }
: { number: 7, title: 'Add the pull request panel' }))
process.exit(0)
}
if (args[0] === 'pr' && args[1] === 'ready' && args[2] === '9') {
state.ready = true
save()
process.stderr.write('Pull request octo/demo#9 is marked as "ready for review"\\n')
process.exit(0)
}
if (args[0] === 'api' && args[5] === 'repos/octo/demo/pulls/9/merge') {
state.mergedAt = new Date().toISOString()
save()
process.stdout.write(JSON.stringify({ sha: 'c'.repeat(40), merged: true, message: 'Pull Request successfully merged' }))
process.exit(0)
}
if (args[0] === 'pr' && args[1] === 'diff') {
const refused = join(here, 'pr-' + args[2] + '.refused')
if (existsSync(refused)) {
process.stderr.write(readFileSync(refused, 'utf8'))
process.exit(1)
}
process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8'))
process.exit(0)
}
if (args[0] === 'api' && args[3] === '-H' && args[4] === 'Accept: application/vnd.github.raw+json') {
const [resource, query] = args[5].split('?')
const path = decodeURIComponent(resource.slice('repos/octo/demo/contents/'.length))
const text = JSON.parse(readFileSync(join(here, 'contents.json'), 'utf8'))[path + '@' + query.slice('ref='.length)]
if (text === undefined) {
process.stderr.write('gh: Not Found (HTTP 404)\\n')
process.exit(1)
}
process.stdout.write(text)
process.exit(0)
}
if (args[0] === 'auth' && args[1] === 'status') {
process.stdout.write('github.com\\n Logged in to github.com account octo\\n')
process.exit(0)
}
if (args[0] === 'api' && args[1] === 'graphql') {
const query = args.find(arg => arg.startsWith('query=')).slice('query='.length)
const check = (name, conclusion, completedAt) => ({
__typename: 'CheckRun', name, status: 'COMPLETED', conclusion,
detailsUrl: 'https://github.com/octo/demo/actions/runs/1', startedAt: '2026-10-09T00:00:00Z', completedAt,
checkSuite: { workflowRun: { workflow: { name: 'CI' } } },
})
const pulls = {
7: {
number: 7, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false,
updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40),
baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED',
reviewThreads: { nodes: [{ isResolved: false }] },
commits: { nodes: [{ commit: { statusCheckRollup: { state: 'FAILURE', contexts: {
pageInfo: { hasNextPage: false },
nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z'), check('test', 'FAILURE', '2026-10-09T00:02:07Z')],
} } } }] },
},
9: {
number: 9, url: 'https://github.com/octo/demo/pull/9', title: 'Add the merge button', state: state.mergedAt === null ? 'OPEN' : 'MERGED',
isDraft: !state.ready, updatedAt: '2026-10-09T00:00:00Z', closedAt: state.mergedAt, author: { login: 'octo' },
headRefName: 'feat/merge', headRefOid: state.head ?? 'b'.repeat(40), baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE',
mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { nodes: [] },
commits: { nodes: [{ commit: { statusCheckRollup: { state: 'SUCCESS', contexts: {
pageInfo: { hasNextPage: false }, nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z')],
} } } }] },
},
}
const settings = {
squashMergeAllowed: true, mergeCommitAllowed: true, rebaseMergeAllowed: true, viewerPermission: 'WRITE', viewerDefaultMergeMethod: 'SQUASH',
}
const data = {}
let repository
for (const match of query.matchAll(/(r\\d+): repository\\(|(p\\d+): pullRequest\\(number: (\\d+)\\)/g)) {
if (match[1] !== undefined) data[repository = match[1]] = { ...settings }
else data[repository][match[2]] = pulls[Number(match[3])] ?? null
}
process.stdout.write(JSON.stringify({ data }))
process.exit(0)
}
process.stderr.write('unexpected gh invocation\\n')
process.exit(1)
`
/** Head commit of pull request 7 in the GitHub CLI double. */
const HEAD7 = 'a'.repeat(40)
/** What gh 2.100 prints for the diff of pull request 9 when GitHub refuses it for having over 300 files. */
const PR9_REFUSAL = [
'could not find pull request diff: HTTP 406: Sorry, the diff exceeded the maximum number of files (300). Consider using \'List pull '
+ 'requests files\' API or locally cloning the repository instead. (https://api.github.com/repos/octo/demo/pulls/9)',
'PullRequest.diff too_large',
'',
].join('\n')
/** Line `n` of `src/panel.ts` before (`old`) or after (`new`) pull request 7, which changes lines 2 and 15 of its 20. */
function panelLine(n: number, side: 'old' | 'new'): string {
if (n === 2) return side === 'old' ? 'export const n2 = 2' : 'export const n2 = \'two\''
if (n === 15) return side === 'old' ? 'export const n15 = 15' : 'export const n15 = \'fifteen\''
return `export const n${n} = ${n}`
}
/** Patch of pull request 7 as `gh pr diff` prints it: two hunks of `src/panel.ts` and the new `docs/panel.md`. */
const PR7_DIFF = [
'diff --git a/src/panel.ts b/src/panel.ts',
'index 1111111..2222222 100644',
'--- a/src/panel.ts',
'+++ b/src/panel.ts',
'@@ -1,5 +1,5 @@',
` ${panelLine(1, 'new')}`, `-${panelLine(2, 'old')}`, `+${panelLine(2, 'new')}`,
...[3, 4, 5].map(n => ` ${panelLine(n, 'new')}`),
'@@ -12,7 +12,7 @@',
...[12, 13, 14].map(n => ` ${panelLine(n, 'new')}`), `-${panelLine(15, 'old')}`, `+${panelLine(15, 'new')}`,
...[16, 17, 18].map(n => ` ${panelLine(n, 'new')}`),
'diff --git a/docs/panel.md b/docs/panel.md',
'new file mode 100644',
'index 0000000..3333333',
'--- /dev/null',
'+++ b/docs/panel.md',
'@@ -0,0 +1,2 @@',
'+# Panel',
'+Shows the change requests of a Session.',
'',
].join('\n')
async function git(cwd: string, ...args: string[]): Promise<string> {
const { stdout } = await run('git', args, { cwd, env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } })
return stdout.trim()
}
/** A Web scaffold with the bundle, a GitHub checkout of `main` and `release`, and the New Session screen of that workspace. */
interface PanelLaunch {
readonly scaffold: WebScaffold
readonly page: Page
/** Temporary directory of the GitHub CLI double and its call log. */
readonly root: string
/** The Session's workspace, a main checkout. */
readonly project: string
readonly tripwire: ReturnType<typeof watchConsole>
}
/**
* Launch the bundle against a GitHub checkout of `main` and `release` and open the New Session screen of that workspace.
* @param failureShot - name of the screenshot saved when the test fails.
* @param scenario - normal conversation, a restoration follow-up, or a numbered PR lookup.
* @returns the scaffold, page, and checkout.
*/
async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal'): Promise<PanelLaunch> {
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-'))
onTestFinished(async () => {
const failures: unknown[] = []
await resources.browser?.close().catch((error: unknown) => failures.push(error))
await resources.scaffold?.close().catch((error: unknown) => failures.push(error))
await rm(root, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
if (failures.length > 0) throw new AggregateError(failures, 'Forge panel teardown failed')
})
const gh = join(root, 'gh')
await writeFile(gh, GH)
await chmod(gh, 0o755)
await writeFile(join(root, 'pr-7.diff'), PR7_DIFF)
await writeFile(join(root, 'pr-9.refused'), PR9_REFUSAL)
const panelText = `${Array.from({ length: 20 }, (_, index) => panelLine(index + 1, 'new')).join('\n')}\n`
await writeFile(join(root, 'contents.json'), JSON.stringify({ [`src/panel.ts@${HEAD7}`]: panelText }))
const overlay = join(root, 'forge.patch.yml')
await writeFile(overlay, `- id: forge-github\n config: ${JSON.stringify({ ghCommand: gh })}\n`)
const override = join(root, 'replay.override.json')
// The borrowed scenario records one model call, so index 1 appends the reply to the follow-up.
if (scenario === 'follow-up') await writeFile(override, JSON.stringify({ patches: [{ at: 1, entry: { kind: 'chunks', chunks: textReply(FOLLOW_UP_REPLY) } }] }))
if (scenario === 'reference') {
const command = `PATH=${JSON.stringify(`.fixture-bin:${process.env.PATH ?? ''}`)} gh pr view 7 --repo octo/demo --json number,title 2>&1 | head -200`
const call = { type: 'tool-call', id: 'view-pr', name: 'bash', arguments: JSON.stringify({ command, description: 'View PR metadata' }) }
await writeFile(override, JSON.stringify({ patches: [
{ at: 0, entry: { kind: 'chunks', chunks: [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: call },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] } },
{ at: 1, entry: { kind: 'chunks', chunks: textReply('Viewed PR 7.') } },
] }))
}
const scaffold = await launchWebScaffold({
profile: { packages: [{ dir: BUNDLE, enabled: true }] },
extraOverlayPath: overlay,
replayFixture: FIXTURE,
...scenario !== 'normal' ? { replayOverride: override } : {},
// The worktree notice, worktree records, and bindings are asserted separately from the borrowed conversation.
compareReplaySession: false,
})
resources.scaffold = scaffold
const project = join(scaffold.workspaceCwd, 'workspace')
await mkdir(project, { recursive: true })
await git(project, 'init', '--quiet', '--initial-branch=main')
await git(project, 'config', 'user.name', 'Forge Panel')
await git(project, 'config', 'user.email', 'forge-panel@example.test')
await git(project, 'config', 'commit.gpgsign', 'false')
await writeFile(join(project, 'README.md'), 'demo\n')
await git(project, 'add', 'README.md')
await git(project, 'commit', '--quiet', '-m', 'init')
await git(project, 'remote', 'add', 'origin', 'https://github.com/octo/demo.git')
await git(project, 'branch', 'release')
await git(project, 'commit', '--quiet', '--allow-empty', '-m', 'main only')
if (scenario === 'reference') {
// The model's confined command must own the CLI double's writable call log.
const modelBin = join(project, '.fixture-bin')
await mkdir(modelBin)
await writeFile(join(modelBin, 'gh'), GH)
await chmod(join(modelBin, 'gh'), 0o755)
}
const browser = await chromium.launch()
resources.browser = browser
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai' })
const tripwire = watchConsole(page)
onTestFailed(async () => { await saveFailureShot(page, failureShot) })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
return { scaffold, page, root, project, tripwire }
}
/** Stream chunks of a model reply that is one text block. */
function textReply(text: string): unknown[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 50, outputTokens: 2, cacheReadTokens: 0, reasoningTokens: 0 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
/**
* Launch the panel and choose the worktree option of the fresh Session from `release`.
* @param failureShot - name of the screenshot saved when the test fails.
* @param followUp - whether the replay answers a second model call.
* @returns the scaffold, page, and checkout.
*/
async function requestReleaseWorktree(failureShot: string, followUp = false): Promise<PanelLaunch> {
const launched = await launchPanel(failureShot, followUp ? 'follow-up' : 'normal')
const { page } = launched
const option = page.locator('[data-forge-worktree]')
await option.waitFor({ timeout: 20_000 })
await option.getByRole('checkbox', { name: '工作树', exact: true }).check()
await option.getByRole('button', { name: '基于 main', exact: true }).click()
await page.getByRole('menuitem', { name: 'release', exact: true }).click()
await option.getByRole('button', { name: '基于 release', exact: true }).waitFor()
return launched
}
/**
* Send the first prompt of the borrowed scenario.
* @returns the prompt and the turn's settlement, which resolves to the Session id.
*/
async function sendPrompt(page: Page, scaffold: WebScaffold): Promise<{ readonly prompt: string; readonly settled: Promise<SessionId> }> {
const [prompt] = fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))
if (prompt === undefined) throw new Error('The borrowed scenario requires a user prompt')
const settled = scaffold.whenTurnSettled()
const composer = page.locator('[data-composer-input]').first()
await writeComposerDraft(page, composer, prompt)
await composer.press('Enter')
return { prompt, settled }
}
function worktreeRecords(events: readonly SessionEvent[]): unknown[] {
return events.flatMap((event) => {
const record = pluginRecordOf(event)
return record?.type === 'plugin:client-ui-forge/worktree' ? [record.data] : []
})
}
it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omits its URL', async () => {
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-numbered-reference', 'reference')
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
const events = await readPersistedEvents(scaffold, sessionId)
const toolOutput = events.flatMap(event => event.type === 'tool/result'
? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []).join('\n')
expect(toolOutput).toContain('Add the pull request panel')
const header = page.locator('[data-forge-header]')
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理')
await header.click()
const change = page.locator(`[data-forge-change="${PULL}"]`)
await change.getByRole('link', { name: 'Add the pull request panel', exact: true }).waitFor({ timeout: 20_000 })
await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'reference.expected.md'),
await captureStableAria(page, `[data-forge-change="${PULL}"]`, scaffold.workspaceCwd), MODE)
expect(events.flatMap((event) => {
const record = pluginRecordOf(event)
return record?.type === 'plugin:forge-session/binding' ? [record.data] : []
})).toEqual([{ action: 'reference', url: PULL }])
const calls = (await readFile(join(project, '.fixture-bin', 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(calls).toContainEqual(['pr', 'view', '7', '--repo', 'octo/demo', '--json', 'url'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('starts a Session in a worktree before its first message, follows its change request, and restores the removed worktree', async () => {
const { scaffold, page, root, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel', true)
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
await page.locator('[data-forge-creation]').waitFor({ state: 'detached' })
await page.locator('[data-forge-worktree]').waitFor({ state: 'detached' })
const session = scaffold.ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('The worktree Session must stay loaded')
const directory = scaffold.ctx.workingDirectory.get(session)
const branch = basename(directory)
expect(directory).toBe(join(project, '.agents', 'worktrees', branch))
expect(branch).toMatch(new RegExp(`^dsh-${sessionId.slice('session-'.length, 'session-'.length + 8)}-[0-9a-f]{4}$`))
expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch)
const baseCommit = await git(project, 'rev-parse', 'release')
expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit)
const events = await readPersistedEvents(scaffold, sessionId)
expect(worktreeRecords(events)).toEqual([
{ action: 'request', from: 'main' },
{ action: 'request', from: 'release' },
{ action: 'created', worktree: { path: directory, branch, from: 'release', baseCommit, repositoryRoot: project } },
])
// The worktree existed before the turn, so the first model request carried its directory notice.
const created = events.findIndex((event) => {
const record = pluginRecordOf(event)
const data = record?.type === 'plugin:client-ui-forge/worktree' ? record.data : null
return typeof data === 'object' && data !== null && 'action' in data && data.action === 'created'
})
const turn = events.findIndex(event => event.type === 'turn/start')
const notice = events.findIndex(event => event.type === 'user/message' && event.data.source.kind === 'working-directory')
expect(created).toBeLessThan(turn)
expect(notice).toBeGreaterThan(turn)
expect(notice).toBeLessThan(events.findIndex(event => event.type === 'assistant/message'))
// A change request bound the way a `gh pr create` result binds one.
scaffold.ctx.forgeSession.track(session, PULL)
const header = page.locator('[data-forge-header]')
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理')
expect(await header.getAttribute('aria-pressed')).toBe('false')
// The Session row marks the worktree and its change request's state, and its hover card names both.
const row = page.locator(`[data-row-key="session:${sessionId}"]`)
await row.getByRole('img', { name: '在自己的工作树中', exact: true }).waitFor({ timeout: 20_000 })
await row.getByRole('img', { name: '变更请求状态:需要处理 · 检查失败', exact: true }).waitFor({ timeout: 20_000 })
await row.hover()
const rowHover = page.locator('[data-forge-session-hover]')
await rowHover.getByText('#7 Add the pull request panel', { exact: true }).waitFor({ timeout: 20_000 })
await compareOrRefreshGolden(join(EXPECTED, 'session-row-hover.expected.md'),
await captureStableAria(page, '[data-forge-session-hover]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE)
await page.mouse.move(0, 0)
await rowHover.waitFor({ state: 'detached' })
await header.click()
await expect.poll(() => header.getAttribute('aria-pressed')).toBe('true')
const panel = page.locator('[data-forge-panel]')
const checkout = panel.locator('[data-forge-checkout]')
const checkoutName = checkout.getByText(branch, { exact: true })
await checkoutName.waitFor({ timeout: 20_000 })
await checkout.getByText('工作树 · 基于 release', { exact: true }).waitFor()
await checkoutName.hover()
await page.getByRole('tooltip').filter({ hasText: `工作树(基于 release):${abbreviateHomePath(directory, homedir())}` }).waitFor()
await page.mouse.move(0, 0)
const change = panel.locator(`[data-forge-change="${PULL}"]`)
const title = change.getByRole('link', { name: 'Add the pull request panel', exact: true })
await title.waitFor({ timeout: 20_000 })
// The only open change request starts expanded.
await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor()
// The random branch suffix can read as a duration, so the branch is replaced before the capture normalizes durations.
await compareOrRefreshGolden(join(EXPECTED, 'panel.expected.md'),
await captureStableAria(page, '[data-forge-panel]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE)
const checksRow = change.getByRole('button', { name: /^检查/ })
await checksRow.click()
const checks = page.getByRole('menu')
await expect.poll(() => checks.getByRole('menuitem').allTextContents()).toEqual([expect.stringMatching(/^test/), expect.stringMatching(/^lint/)])
await page.keyboard.press('Escape')
await checks.waitFor({ state: 'detached' })
expect(await checksRow.getAttribute('aria-expanded')).toBe('false')
const bindings = async () => (await readPersistedEvents(scaffold, sessionId)).flatMap((event) => {
const record = pluginRecordOf(event)
return record?.type === 'plugin:forge-session/binding' ? [record.data] : []
})
await change.getByRole('button', { name: '从本会话移除 #7', exact: true }).click()
await change.waitFor({ state: 'detached' })
await panel.getByText('本会话还没有变更请求', { exact: true }).waitFor()
await page.getByRole('alert').filter({ hasText: '已从本会话移除 #7' }).getByRole('button', { name: '撤销', exact: true }).click()
await title.waitFor({ timeout: 20_000 })
await expect.poll(bindings).toEqual([{ action: 'track', url: PULL }, { action: 'dismiss', url: PULL }, { action: 'track', url: PULL }])
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(calls).toContainEqual(['auth', 'status', '--hostname', 'github.com'])
expect(calls.some(args => args[0] === 'api' && args[1] === 'graphql' && args.includes('--hostname'))).toBe(true)
await header.click()
await expect.poll(() => header.getAttribute('aria-pressed')).toBe('false')
// Removing the worktree outside the Session holds the next message in the inbox until the worktree is restored.
await git(project, 'worktree', 'remove', '--force', directory)
const composer = page.locator('[data-composer-input]').first()
await writeComposerDraft(page, composer, FOLLOW_UP)
const accepted = page.waitForResponse('**/api/session/prompt')
await composer.press('Enter')
const envelope = await (await accepted).json() as { readonly result: unknown }
expect(envelope.result).toMatchObject({ ok: true })
const missing = page.locator('[data-forge-missing]')
await missing.getByText(`可以在原位置恢复分支 ${branch} 的工作树。删除前未提交的修改无法恢复`, { exact: true }).waitFor()
await missing.getByText('恢复工作树或在项目目录继续后,消息会接着发送', { exact: true }).waitFor()
await expect.poll(() => checkout.getAttribute('data-missing')).toBe('')
const turns = async (): Promise<number> => (await readPersistedEvents(scaffold, sessionId)).filter(event => event.type === 'turn/start').length
expect(await turns()).toBe(1)
await missing.getByRole('button', { name: '恢复工作树', exact: true }).click()
await missing.waitFor({ state: 'detached', timeout: 20_000 })
await page.getByRole('alert').filter({ hasText: '工作树已恢复' }).waitFor()
expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch)
expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit)
await expect.poll(() => checkout.getAttribute('data-missing')).toBeNull()
// The waiting message goes on in the restored worktree.
await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor({ timeout: 20_000 })
expect(await turns()).toBe(2)
expect(scaffold.ctx.workingDirectory.get(session)).toBe(directory)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('keeps the first message waiting when the worktree cannot be created, tries again, and sends it without the worktree', async () => {
const { scaffold, page, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel-failure')
await git(project, 'branch', '--quiet', '-D', 'release')
const { settled } = await sendPrompt(page, scaffold)
const card = page.locator('[data-forge-creation="failed"]')
const failure = card.getByRole('region', { name: '创建工作树失败', exact: true })
await failure.getByText('原因', { exact: true }).waitFor({ timeout: 20_000 })
const attempted = await failure.getByRole('definition').first().textContent()
expect(attempted).toMatch(/^dsh-[0-9a-f]{8}-[0-9a-f]{4}$/)
await failure.getByText('消息会在工作树建好后发送', { exact: true }).waitFor()
// Retry makes another attempt with a new branch name while the message keeps waiting.
await card.getByRole('button', { name: '重试', exact: true }).click()
await expect.poll(() => failure.getByRole('definition').first().textContent(), { timeout: 20_000 }).not.toBe(attempted)
await card.getByRole('button', { name: '不使用工作树', exact: true }).click()
const sessionId = await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
await card.waitFor({ state: 'detached' })
const session = scaffold.ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('The Session must stay loaded')
expect(scaffold.ctx.workingDirectory.get(session)).toBe(project)
const events = await readPersistedEvents(scaffold, sessionId)
expect(worktreeRecords(events)).toEqual([{ action: 'request', from: 'main' }, { action: 'request', from: 'release' }, { action: 'cancel' }])
expect(events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(events.some(event => event.type === 'user/message' && event.data.source.kind === 'working-directory')).toBe(false)
await page.locator('[data-forge-header]').click({ timeout: 20_000 })
const checkout = page.locator('[data-forge-panel] [data-forge-checkout]')
const checkoutName = checkout.getByText('main', { exact: true })
await checkoutName.waitFor({ timeout: 20_000 })
await checkout.getByText('项目目录', { exact: true }).waitFor()
await checkoutName.hover()
await page.getByRole('tooltip').filter({ hasText: `项目目录:${abbreviateHomePath(project, homedir())}` }).waitFor()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('marks a draft change request ready for review and merges it with the usual method', async () => {
const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-panel-merge')
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
const session = scaffold.ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('The Session must stay loaded')
scaffold.ctx.forgeSession.track(session, DRAFT)
const header = page.locator('[data-forge-header]')
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求')
await header.click()
const change = page.locator(`[data-forge-change="${DRAFT}"]`)
await change.getByRole('link', { name: 'Add the merge button', exact: true }).waitFor({ timeout: 20_000 })
// The only open change request starts expanded, so its action is in view.
await change.getByRole('button', { name: '标记为可评审', exact: true }).click()
await page.getByRole('alert').filter({ hasText: '已将 #9 标记为可评审' }).waitFor()
const merge = change.getByRole('button', { name: '合并', exact: true })
await merge.waitFor({ timeout: 20_000 })
await merge.click()
const menu = page.getByRole('menu')
await menu.getByText('将 #9 合并到 main', { exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'merge-menu.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
await writeFile(join(root, 'state.json'), JSON.stringify({ ready: true, mergedAt: null, head: 'c'.repeat(40) }))
scaffold.ctx.forge.refresh({ repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number: 9 })
const stale = menu.getByRole('menuitem', { name: '提交已更新,请重新检查后再打开合并菜单', exact: true })
await stale.waitFor()
expect(await stale.isDisabled()).toBe(true)
await compareOrRefreshGolden(join(EXPECTED, 'merge-stale.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
await merge.click()
await merge.click()
await menu.getByRole('menuitem', { name: '确认合并', exact: true }).click()
await page.getByRole('alert').filter({ hasText: '已合并 #9' }).waitFor()
// Merged, the change request is no longer open, so its card collapses to the merge line.
await change.getByText(/^已合并到 main · /).waitFor({ timeout: 20_000 })
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(calls).toContainEqual(['pr', 'ready', '9', '--repo', 'github.com/octo/demo'])
expect(calls).toContainEqual([
'api', '--hostname', 'github.com', '--method', 'PUT', 'repos/octo/demo/pulls/9/merge', '-f', 'merge_method=squash', '-f', `sha=${'c'.repeat(40)}`,
])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('shows the local changes of the Session by scope and the changes of its change requests, expanding unchanged lines', async () => {
const { scaffold, page, root, project, tripwire } = await launchPanel('web-e2e-forge-panel-changes')
// origin/main stops at the twelve-line README; the branch adds a commit, a staged file, an edit, and an untracked file.
await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => `line ${index + 1}\n`).join(''))
await git(project, 'commit', '--quiet', '-am', 'readme')
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
await mkdir(join(project, 'src'))
await writeFile(join(project, 'src', 'app.ts'), 'export const app = 1\n')
await git(project, 'add', 'src/app.ts')
await git(project, 'commit', '--quiet', '-m', 'app')
await writeFile(join(project, 'src', 'extra.ts'), 'export const extra = 1\n')
await git(project, 'add', 'src/extra.ts')
await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => index === 9 ? 'line ten\n' : `line ${index + 1}\n`).join(''))
await writeFile(join(project, 'notes.md'), 'note\n')
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
const session = scaffold.ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('The Session must stay loaded')
const header = page.locator('[data-forge-header]')
await header.click()
// The New Session screen inspected the checkout before these changes, which came from outside the Session.
await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click()
const card = page.locator('[data-forge-local]')
await card.getByText('HEAD → origin/main', { exact: true }).waitFor({ timeout: 20_000 })
await card.getByRole('button', { name: '查看本会话的变更:4 个文件,新增 4 行,删除 1 行', exact: true }).click()
const local = page.locator('[data-forge-changes="local"]')
await local.getByText('与 origin/main 比较,包含未提交的修改', { exact: true }).waitFor({ timeout: 20_000 })
const files = local.locator('[data-forge-diff-file]')
await expect.poll(() => files.evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))), { timeout: 20_000 })
.toEqual(['README.md', 'src/app.ts', 'src/extra.ts', 'notes.md'])
await local.locator('[data-forge-diff-file="notes.md"]').getByText('新文件 · 未提交', { exact: true }).waitFor()
const readme = local.locator('[data-forge-diff-file="README.md"]')
await readme.getByRole('button', { name: '6 行未变更', exact: true }).click()
await readme.getByText('line 1', { exact: true }).first().waitFor({ timeout: 20_000 })
await local.getByRole('button', { name: '分支', exact: true }).click()
await page.getByRole('menuitem', { name: '已暂存', exact: true }).click()
await local.getByText('暂存区与 HEAD 比较', { exact: true }).waitFor()
await expect.poll(() => files.evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))), { timeout: 20_000 })
.toEqual(['src/extra.ts'])
// A change request bound the way a `gh pr create` result binds one.
scaffold.ctx.forgeSession.track(session, PULL)
await expect.poll(() => header.getAttribute('aria-pressed'), { timeout: 20_000 }).toBe('false')
await header.click()
const change = page.locator(`[data-forge-change="${PULL}"]`)
await change.getByRole('link', { name: 'Add the pull request panel', exact: true }).waitFor({ timeout: 20_000 })
await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click()
const view = page.locator('[data-forge-changes="change"]')
const panelFile = view.locator('[data-forge-diff-file="src/panel.ts"]')
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).waitFor({ timeout: 20_000 })
await compareOrRefreshGolden(join(EXPECTED, 'changes-view.expected.md'),
await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE)
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).click()
await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 })
await panelFile.getByRole('button', { name: '2 行未变更', exact: true }).waitFor()
// GitHub refuses the diff of pull request 9 as too large, so its view offers the web page instead of a retry.
scaffold.ctx.forgeSession.track(session, DRAFT)
await expect.poll(() => header.getAttribute('aria-pressed'), { timeout: 20_000 }).toBe('false')
await header.click()
const draft = page.locator(`[data-forge-change="${DRAFT}"]`)
await draft.getByRole('button', { name: '展开 #9', exact: true }).click()
await draft.getByRole('button', { name: '查看 #9 的变更:新增 30 行,删除 2 行', exact: true }).click({ timeout: 20_000 })
const refused = page.locator('[data-forge-changes="change"]').filter({ hasText: 'Add the merge button' })
await refused.getByRole('status').filter({ hasText: '变更太大,无法在这里显示' }).waitFor({ timeout: 20_000 })
await refused.getByRole('button', { name: '在浏览器中查看', exact: true }).waitFor()
expect(await refused.getByRole('button', { name: '重试', exact: true }).count()).toBe(0)
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(calls).toContainEqual(['pr', 'diff', '7', '--repo', 'github.com/octo/demo', '--color', 'never'])
expect(calls).toContainEqual(['pr', 'diff', '9', '--repo', 'github.com/octo/demo', '--color', 'never'])
expect(calls).toContainEqual([
'api', '--hostname', 'github.com', '-H', 'Accept: application/vnd.github.raw+json', `repos/octo/demo/contents/src/panel.ts?ref=${HEAD7}`,
])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
+1
View File
@@ -863,6 +863,7 @@ describe('web e2e: plugin manager', () => {
['@deepseek-ai/dsh-experimental-voice-input-bundle', 1],
['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
['@deepseek-ai/dsh-experimental-auto-review', 1],
['@deepseek-ai/dsh-experimental-forge-bundle', 1],
['@deepseek-ai/dsh-personalized-recommendations', 1],
['@deepseek-ai/dsh-experimental-inspector-profile', 1],
['@deepseek-ai/dsh-experimental-tool-worktree', 1],
+48 -1
View File
@@ -2,9 +2,11 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { monitorEventLoopDelay } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import type {} from '@deepseek-ai/dsh-tool-present/types'
import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import {
@@ -20,6 +22,18 @@ const MODE = webSnapshotMode()
const RECORD_PROMPT = '简单画一个 SVG 表示冯诺依曼架构,保存为 von-neumann.svg,并提供独立文件卡片,方便打开。'
const FILE = 'von-neumann.svg'
/** Queue counters from the installed ws implementation; payload contents are never recorded. */
type DiagnosticSocket = WebSocket & {
readonly _sender?: { readonly _state: number; readonly _bufferedBytes: number; readonly _queue: readonly unknown[] }
readonly _receiver?: { readonly _state: number; readonly _bufferedBytes: number }
readonly _socket?: {
readonly bytesRead: number
readonly bytesWritten: number
readonly readableLength: number
readonly writableLength: number
}
}
describe('web e2e: requested SVG is explicitly delivered', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -27,9 +41,40 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
let tripwire: ReturnType<typeof watchConsole>
let cwd: string
let replayRoot: string | undefined
let restoreTransportDiagnostics: (() => void) | undefined
const connectionDiagnostics: string[] = []
beforeAll(async () => {
const loopDelay = monitorEventLoopDelay({ resolution: 20 })
loopDelay.enable()
const ping = vi.spyOn(WebSocket.prototype, 'ping')
const terminated = vi.spyOn(WebSocket.prototype, 'terminate').mockImplementation(function (this: WebSocket) {
const socket = this as DiagnosticSocket
connectionDiagnostics.push(`Host termination: ${JSON.stringify({
readyState: socket.readyState,
bufferedBytes: socket.bufferedAmount,
pingCalls: ping.mock.contexts.filter(context => context === socket).length,
senderState: socket._sender?._state,
senderBufferedBytes: socket._sender?._bufferedBytes,
senderQueueLength: socket._sender?._queue.length,
receiverState: socket._receiver?._state,
receiverBufferedBytes: socket._receiver?._bufferedBytes,
bytesRead: socket._socket?.bytesRead,
bytesWritten: socket._socket?.bytesWritten,
readableLength: socket._socket?.readableLength,
writableLength: socket._socket?.writableLength,
maxEventLoopDelayMs: loopDelay.max / 1e6,
p99EventLoopDelayMs: loopDelay.percentile(99) / 1e6,
})}`)
connectionDiagnostics.push(new Error('Host WebSocket termination').stack ?? 'Host WebSocket termination')
terminated.mockRestore()
this.terminate()
})
restoreTransportDiagnostics = () => {
terminated.mockRestore()
ping.mockRestore()
loopDelay.disable()
}
let replayOverride: string | undefined
if (MODE !== 'record') {
replayRoot = await mkdtemp(join(tmpdir(), 'dsh-present-svg-replay-'))
@@ -53,6 +98,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
if (message.text().startsWith('[connection]')) connectionDiagnostics.push(message.text())
})
page.on('websocket', (socket) => {
loopDelay.reset()
const openedAt = Date.now()
let received = 0
socket.on('framereceived', () => { received++ })
@@ -67,6 +113,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
})
afterAll(async () => {
restoreTransportDiagnostics?.()
try {
await browser?.close()
} finally {
+64 -25
View File
@@ -164,6 +164,8 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST')
await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click()
expect((await revealResponse).status()).toBe(204)
await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
expect(await page.getByRole('alert').count()).toBe(0)
await expect.poll(opened).toHaveLength(beforeReveal + 1)
expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) })
for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) {
@@ -172,34 +174,11 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
await row.locator('[data-presented-file]').filter({ hasText: name }).getByRole('button', { name: 'Open in Test Editor', exact: true }).click()
expect((await response).status()).toBe(204)
await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
expect(await page.getByRole('alert').count()).toBe(0)
expect(await row.locator('[data-presented-file] [role="status"]').count()).toBe(0)
expect(await opened()).toHaveLength(count + 1)
expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes })
}
const success = row.locator('[data-presented-file]').filter({ hasText: '说明.txt' }).locator('[data-success]')
await success.waitFor()
const animation = await success.evaluate((element) => {
const style = getComputedStyle(element)
const fade = element.getAnimations()[0]
if (fade === undefined) throw new Error('Successful file feedback has no fade animation')
// Seek the CSS animation so the 200 ms fade does not race the assertion.
fade.pause()
fade.currentTime = 5100
return {
delay: style.animationDelay, duration: style.animationDuration,
fill: style.animationFillMode, opacity: Number(getComputedStyle(element).opacity),
}
})
expect(animation.delay).toBe('5s')
expect(animation.duration).toBe('0.2s')
expect(animation.fill).toBe('forwards')
expect(animation.opacity).toBeGreaterThan(0)
expect(animation.opacity).toBeLessThan(1)
try {
await page.emulateMedia({ reducedMotion: 'reduce' })
expect(await success.evaluate(element => getComputedStyle(element).animationName)).toBe('none')
} finally {
await page.emulateMedia({ reducedMotion: 'no-preference' })
}
}
expect(downloads).toEqual([])
const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href)
@@ -318,4 +297,64 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('announces mobile native opens and reveals on another device, and reports a deleted file', async () => {
const context = await browser.newContext({
storageState: await page.context().storageState(),
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1',
viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'en-US', timezoneId: 'Asia/Shanghai',
})
try {
const phone = await context.newPage()
const consoleWatch = watchConsole(phone)
const phoneDownloads: string[] = []
phone.on('download', (download) => { phoneDownloads.push(download.suggestedFilename()) })
await phone.goto(page.url(), { waitUntil: 'load' })
await phone.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 })
const card = phone.locator('[data-presented-file]').filter({ hasText: '说明.txt' })
const open = card.getByRole('button', { name: 'Open in Test Editor', exact: true })
await open.waitFor()
const notices: string[] = []
const beforeOpen = (await opened()).length
const openResponse = phone.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
await open.tap()
expect((await openResponse).status()).toBe(204)
const success = phone.getByRole('alert').filter({ hasText: 'Opened on another device' })
await success.waitFor()
notices.push(`## Open\n\n${await success.ariaSnapshot()}`)
await expect.poll(opened).toHaveLength(beforeOpen + 1)
expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, '说明.txt')), content: 'EDITED_NOTE\n' })
expect(await card.locator('[role="status"]').count()).toBe(0)
const bounds = await success.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.x).toBeGreaterThanOrEqual(0)
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390)
await card.getByRole('button', { name: 'More ways to open', exact: true }).tap()
const revealResponse = phone.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST')
await phone.getByRole('menuitem', { name: 'Show file location', exact: true }).tap()
expect((await revealResponse).status()).toBe(204)
const revealed = phone.getByRole('alert').filter({ hasText: 'File location shown on another device' })
await revealed.waitFor()
notices.push(`## Show file location\n\n${await revealed.ariaSnapshot()}`)
await expect.poll(opened).toHaveLength(beforeOpen + 2)
expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, '说明.txt') : cwd) })
await unlink(join(cwd, '说明.txt'))
const missingResponse = phone.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
await open.tap()
expect((await missingResponse).status()).toBe(404)
const failure = phone.getByRole('alert').filter({ hasText: 'Could not open. Try again.' })
await failure.waitFor()
notices.push(`## Missing file\n\n${await failure.ariaSnapshot()}`)
await card.getByText('Could not open. Click to retry.', { exact: true }).waitFor()
expect(await opened()).toHaveLength(beforeOpen + 2)
expect(phoneDownloads).toEqual([])
await compareOrRefreshGolden(join(DIR, 'mobile-open.expected.md'), notices.join('\n\n'), MODE)
expect(consoleWatch.pageErrors).toEqual([])
expect(consoleWatch.warnings).toEqual([])
} finally {
await context.close()
}
})
})
+104 -1
View File
@@ -1,4 +1,4 @@
/** Built settings controls share radius and card materials across account, model, and plugin pages. */
/** Built settings controls share materials and adapt their geometry to the viewport and pointer. */
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Locator } from 'playwright'
@@ -23,6 +23,109 @@ function appearance(element: Locator) {
})
}
function rowGeometry(control: Locator) {
return control.evaluate((node) => {
const row = node.parentElement!
const text = row.firstElementChild!
const description = text.lastElementChild!.getBoundingClientRect()
const rowBox = row.getBoundingClientRect()
const textBox = text.getBoundingClientRect()
const controlBox = node.getBoundingClientRect()
return {
widthDifference: rowBox.width - controlBox.width,
leftDifference: controlBox.left - textBox.left,
rightGap: rowBox.right - controlBox.right,
afterText: controlBox.left - textBox.right,
belowDescription: controlBox.top - description.bottom,
centerDifference: (controlBox.top + controlBox.bottom - textBox.top - textBox.bottom) / 2,
height: controlBox.height,
}
})
}
it('stacks font fields at narrow widths while keeping font-size controls on the right', async () => {
const scaffold = await launchWebScaffold({ developerTools: false })
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
for (const platform of ['web', 'darwin', 'win32'] as const) {
const context = await browser.newContext({
viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: platform === 'web',
})
try {
const page = await context.newPage()
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置', exact: true })
await dialog.getByRole('button', { name: '更多字体设置', exact: true }).click()
await page.evaluate((platform) => {
document.documentElement.toggleAttribute('data-windows-titlebar', platform === 'win32')
if (platform === 'darwin') document.documentElement.setAttribute('data-platform', platform)
}, platform)
for (const width of platform === 'web' ? [390, 767, 768] : [520, 767, 768]) {
await page.setViewportSize({ width, height: 844 })
for (const name of ['正文字体', '代码字体', '终端字体']) {
const geometry = await rowGeometry(dialog.getByRole('textbox', { name, exact: true }).locator('..'))
if (width <= 767) {
expect(Math.abs(geometry.widthDifference), `${platform} ${width}px ${name} width`).toBeLessThan(1)
expect(Math.abs(geometry.leftDifference), `${platform} ${width}px ${name} alignment`).toBeLessThan(1)
expect(geometry.belowDescription, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0)
expect(geometry.height, `${platform} ${width}px ${name} height`).toBeLessThan(60)
} else {
expect(geometry.afterText, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0)
}
}
for (const name of ['增大字号', '增大代码字号', '增大终端字号']) {
const geometry = await rowGeometry(dialog.getByRole('button', { name, exact: true }).locator('../../..'))
expect(geometry.afterText, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0)
expect(Math.abs(geometry.rightGap), `${platform} ${width}px ${name} alignment`).toBeLessThan(1)
expect(Math.abs(geometry.centerDifference), `${platform} ${width}px ${name} centering`).toBeLessThan(1)
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
}
const field = dialog.getByRole('textbox', { name: '正文字体', exact: true })
await field.fill('Uncommitted font draft')
const original = await field.elementHandle()
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 844 })
expect(await original!.evaluate(input => input === document.activeElement && input.isConnected)).toBe(true)
expect(await field.inputValue()).toBe('Uncommitted font draft')
}
} finally {
await context.close()
}
}
})
it('uses 16px text for coarse-pointer font fields and preserves compact fine-pointer fields', async () => {
const scaffold = await launchWebScaffold({ developerTools: false })
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
for (const hasTouch of [true, false]) {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch })
try {
const page = await context.newPage()
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置', exact: true })
await dialog.getByRole('button', { name: '更多字体设置', exact: true }).click()
expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch)
for (const name of ['正文字体', '代码字体', '终端字体']) {
const fontSize = await dialog.getByRole('textbox', { name, exact: true })
.evaluate(input => getComputedStyle(input).fontSize)
expect(fontSize, name).toBe(hasTouch ? '16px' : '14px')
}
} finally {
await context.close()
}
}
})
it('shares settings card materials and control sizes in both palettes', async () => {
const scaffold = await launchWebScaffold({})
onTestFinished(() => scaffold.close())
+1 -1
View File
@@ -681,7 +681,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY')
const transport = await controlTransport(page)
await page.reload({ waitUntil: 'load' })
await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY')
await page.locator('.xterm-rows:visible').filter({ hasText: 'RECONNECT_READY' }).waitFor({ timeout: 30_000 })
await transport.disconnect()
const reconnect = page.getByRole('button', { name: 'Reconnect', exact: true })
await reconnect.waitFor()
+2
View File
@@ -53,6 +53,7 @@
"tests/voice-input.e2e.ts",
"tests/cot-translation.e2e.ts",
"tests/cot-translation-paid.e2e.ts",
"tests/forge-panel.e2e.ts",
"tests/voice-setup.e2e.ts",
"tests/voice-download.e2e.ts",
"tests/prefix-proxy.ts",
@@ -163,6 +164,7 @@
"tests/present.e2e.ts",
"tests/present-svg.e2e.ts",
"tests/present-after-closing-reply.e2e.ts",
"tests/autolink-cjk-punctuation.e2e.ts",
"tests/composer-draft-scroll.e2e.ts",
"tests/composer-focus.e2e.ts",
"tests/composer-placeholder.e2e.ts",
+57 -45
View File
@@ -164,20 +164,22 @@ interface Workload {
readonly evaluationsPerSample: number
}
/** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */
function workloads(): readonly Workload[] {
/** Scaling sizes share adjacent rounds; standalone workloads occupy one group. */
function workloadGroups(): readonly (readonly Workload[])[] {
return [
{ id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 },
[{ id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 }],
...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL })
.map(([name, unit]): Workload => ({
.map(([name, unit]): readonly Workload[] => [{
id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS,
})),
...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({
}]),
...Object.entries(CREDENTIALS).map(([name, credential]): readonly Workload[] => [{
id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), evaluationsPerSample: 1,
})),
{ id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS },
{ id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS },
...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [
}]),
[
{ id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS },
{ id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS },
],
...Object.entries(ADVERSARIAL_UNITS).map(([name, unit]): readonly Workload[] => [
{
id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)],
evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS,
@@ -256,25 +258,34 @@ async function evaluatePair(texts: readonly string[], index: number, evaluations
return { base: await sample(withoutSecret, texts, evaluations), secret }
}
async function measure(texts: readonly string[], attempts: number, evaluations: number): Promise<SamplePair & {
/** Alternate scaling sizes within every warm-up and measured round to limit drift between them. */
async function measure(workloads: readonly Workload[], attempts: number): Promise<readonly (SamplePair & {
readonly workload: Workload
readonly samples: readonly UserTextTimingPair[]
}> {
for (let warm = 0; warm < WARMUP_PAIRS; warm++) await evaluatePair(texts, warm, evaluations)
const pairs: SamplePair[] = []
for (let attempt = 0; attempt < attempts; attempt++) {
pairs.push(await evaluatePair(texts, attempt, evaluations))
}
const best = pairs.reduce((min, pair) => ({
base: pair.base.ms < min.base.ms ? pair.base : min.base,
secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret,
}))
return {
...best,
samples: pairs.map(pair => ({
baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs,
withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs,
})),
})[]> {
const measured = workloads.map(workload => ({ workload, pairs: new Array<SamplePair>() }))
for (let round = 0; round < WARMUP_PAIRS + attempts; round++) {
for (let position = 0; position < measured.length; position++) {
const index = round % 2 === 0 ? position : measured.length - 1 - position
const { workload, pairs } = measured[index]!
const pair = await evaluatePair(workload.texts, round, workload.evaluationsPerSample)
if (round >= WARMUP_PAIRS) pairs.push(pair)
}
}
return measured.map(({ workload, pairs }) => {
const best = pairs.reduce((min, pair) => ({
base: pair.base.ms < min.base.ms ? pair.base : min.base,
secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret,
}))
return {
workload,
...best,
samples: pairs.map(pair => ({
baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs,
withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs,
})),
}
})
}
declare const gc: (() => void) | undefined
@@ -366,7 +377,7 @@ async function allocations(): Promise<UserTextAllocationReport> {
if (gc === undefined) throw new Error('user-text allocation mode needs --expose-gc')
const collect = gc
const results: UserTextWorkloadAllocation[] = []
for (const { id, texts } of workloads()) {
for (const { id, texts } of workloadGroups().flat()) {
results.push({ id, ruleAllocatedBytes: await allocatedBytes(texts, collect), ...returned(texts) })
}
return { workloads: results }
@@ -374,23 +385,24 @@ async function allocations(): Promise<UserTextAllocationReport> {
async function timings(attempts: number): Promise<UserTextTimeReport> {
const results: UserTextWorkloadTiming[] = []
for (const { id, group, texts, evaluationsPerSample } of workloads()) {
const { base, secret, samples } = await measure(texts, attempts, evaluationsPerSample)
results.push({
id,
group,
documents: texts.length,
characters: texts.reduce((sum, text) => sum + text.length, 0),
evaluationsPerSample,
secrets: secret.secrets,
maskedCharacters: secret.maskedCharacters,
baseMs: base.ms,
baseCpuMs: base.cpuMs,
withSecretMs: secret.ms,
withSecretCpuMs: secret.cpuMs,
ratio: secret.ms / base.ms,
samples,
})
for (const workloads of workloadGroups()) {
for (const { workload: { id, group, texts, evaluationsPerSample }, base, secret, samples } of await measure(workloads, attempts)) {
results.push({
id,
group,
documents: texts.length,
characters: texts.reduce((sum, text) => sum + text.length, 0),
evaluationsPerSample,
secrets: secret.secrets,
maskedCharacters: secret.maskedCharacters,
baseMs: base.ms,
baseCpuMs: base.cpuMs,
withSecretMs: secret.ms,
withSecretCpuMs: secret.cpuMs,
ratio: secret.ms / base.ms,
samples,
})
}
}
return { warmupPairs: WARMUP_PAIRS, attempts, workloads: results }
}
+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
en: 506d61dfa8c71186
zh: 46f05c4ef2f5c322
en: cbe99e1985f2deae
zh: 6e524e7a644ae2d6
+17
View File
@@ -36,6 +36,13 @@ flowchart LR
svc_connection["ctx.connection<br/>Authenticated browser transport"]
pkg_api_gateway["api-gateway"]
pkg_host_frontend_static["host-frontend-static"]
pkg_experimental_forge["experimental-forge"]
svc_forge["ctx.forge<br/>Code host change requests"]
pkg_experimental_forge_github["experimental-forge-github"]
pkg_experimental_forge_session["experimental-forge-session"]
pkg_experimental_client_ui_forge["experimental-client-ui-forge"]
svc_forgeSession["ctx.forgeSession<br/>Session change request bindings"]
svc_forgePanel["ctx.forgePanel<br/>Forge panel Remote"]
pkg_experimental_worktree["experimental-worktree"]
svc_worktrees["ctx.worktrees<br/>Git worktree creation"]
pkg_experimental_tool_worktree["experimental-tool-worktree"]
@@ -356,8 +363,12 @@ flowchart LR
pkg_experimental_browser_use_stagehand_native --> svc_browserUse
pkg_experimental_claude_code_mods --> svc_claudeCodeMods
pkg_experimental_client_ui_cot_translation --> svc_cotTranslation
pkg_experimental_client_ui_forge --> svc_forgePanel
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
pkg_experimental_forge --> svc_forge
pkg_experimental_forge_github --> svc_forge
pkg_experimental_forge_session --> svc_forgeSession
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
pkg_experimental_speech_to_text --> svc_speechToText
pkg_experimental_speech_to_text_sensevoice --> svc_speechToText
@@ -507,6 +518,9 @@ flowchart LR
svc_dynamicCordisRunner --> pkg_tool_cordis
svc_fileReferences --> pkg_api_session_controller
svc_fileUploads --> pkg_api_session_controller
svc_forge --> pkg_experimental_client_ui_forge
svc_forge --> pkg_experimental_forge_session
svc_forgeSession --> pkg_experimental_client_ui_forge
svc_fs --> pkg_tool_fs
svc_headlessStartup --> pkg_headless
svc_hmr --> pkg_app_boot
@@ -642,6 +656,9 @@ flowchart LR
| `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | Fetches and validates the index sources the user configures; installation goes through the plugin manager. |
| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. |
| `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams. |
| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop. |
| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | A Session projection derives bindings from shell results and binding records and publishes their URLs without status to clients; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry. |
| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | Streams one panel state per followed Session: a read-only Git inspection in the Session sandbox, the recognized code host and its sign-in, and the bound change requests with polled status. |
| `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | Explicit experimental creation from a pinned local commit. Existing write permissions govern checkout and shared Git metadata; the working-directory service owns the resulting Session directory. |
| `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | One Session projection owns the effective execution directory. The filesystem validates changes; user context reports them while original metadata and write grants stay fixed. |
| `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. |
+17
View File
@@ -38,6 +38,13 @@ flowchart LR
svc_connection["ctx.connection<br/>Authenticated browser transport"]
pkg_api_gateway["api-gateway"]
pkg_host_frontend_static["host-frontend-static"]
pkg_experimental_forge["experimental-forge"]
svc_forge["ctx.forge<br/>Code host change requests"]
pkg_experimental_forge_github["experimental-forge-github"]
pkg_experimental_forge_session["experimental-forge-session"]
pkg_experimental_client_ui_forge["experimental-client-ui-forge"]
svc_forgeSession["ctx.forgeSession<br/>Session change request bindings"]
svc_forgePanel["ctx.forgePanel<br/>Forge panel Remote"]
pkg_experimental_worktree["experimental-worktree"]
svc_worktrees["ctx.worktrees<br/>Git worktree creation"]
pkg_experimental_tool_worktree["experimental-tool-worktree"]
@@ -358,8 +365,12 @@ flowchart LR
pkg_experimental_browser_use_stagehand_native --> svc_browserUse
pkg_experimental_claude_code_mods --> svc_claudeCodeMods
pkg_experimental_client_ui_cot_translation --> svc_cotTranslation
pkg_experimental_client_ui_forge --> svc_forgePanel
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
pkg_experimental_forge --> svc_forge
pkg_experimental_forge_github --> svc_forge
pkg_experimental_forge_session --> svc_forgeSession
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
pkg_experimental_speech_to_text --> svc_speechToText
pkg_experimental_speech_to_text_sensevoice --> svc_speechToText
@@ -509,6 +520,9 @@ flowchart LR
svc_dynamicCordisRunner --> pkg_tool_cordis
svc_fileReferences --> pkg_api_session_controller
svc_fileUploads --> pkg_api_session_controller
svc_forge --> pkg_experimental_client_ui_forge
svc_forge --> pkg_experimental_forge_session
svc_forgeSession --> pkg_experimental_client_ui_forge
svc_fs --> pkg_tool_fs
svc_headlessStartup --> pkg_headless
svc_hmr --> pkg_app_boot
@@ -644,6 +658,9 @@ flowchart LR
| `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | 获取并校验用户配置的索引源;安装通过插件管理器完成。 |
| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 |
| `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | 负责浏览器认证与共享 HTTP 请求分发;API 适配器注册端点和流。 |
| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | 提供方映射远端与变更请求 URL、按 head 分支查找变更请求并批量读取状态;同一变更请求的监听方共享一个轮询循环。 |
| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Session 投影从 shell 结果与绑定记录派生绑定,并把不含状态的 URL 发布给客户端;推送结果触发按 head 分支的发现,Session 监听方通过 forge 注册表轮询已绑定的变更请求。 |
| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | 为每个被跟随的 Session 推送一份面板状态:在 Session 沙箱中进行的只读 Git 检查、识别出的代码托管平台及其登录状态,以及带轮询状态的已绑定变更请求。 |
| `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | 显式实验组合从固定的本地提交创建工作树。检出目录与共享 Git 元数据受现有写权限约束;工作目录服务拥有创建后的 Session 目录。 |
| `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | 一个 Session 投影拥有有效执行目录。文件系统验证变更,用户上下文报告目录信息,原始元数据与写入授权保持不变。 |
| `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 |
+102 -1
View File
@@ -1027,6 +1027,44 @@ export interface CotTranslationPreferences {
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-cot-translation -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-forge -->
<a id="deepseek-aidsh-experimental-client-ui-forge"></a>
## `@deepseek-ai/dsh-experimental-client-ui-forge`
- `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents`
- `source`: [`packages/experimental/client-ui-forge/src/types.ts:318`](../packages/experimental/client-ui-forge/src/types.ts)
```ts config-catalog
/** Host settings of the forge panel Remote. */
export interface Config {
/** Git executable name or absolute path in the Session's execution world. */
gitCommand?: string
/** Deadline in milliseconds for one Git command, including confinement. */
gitTimeoutMs?: number
/** Termination grace period in milliseconds for each Git process. */
graceMs?: number
/** Maximum captured bytes per Git output stream. */
maxOutputBytes?: number
/** Deadline in milliseconds for one provider sign-in probe. */
readinessTimeoutMs?: number
/** Most local branches listed as worktree bases. */
maxBranches?: number
/**
* Prefix of the branch and checkout directory created for a Session worktree; the first eight
* hexadecimal digits of the Session id's UUID, a hyphen, and four random hexadecimal digits follow it.
*/
worktreePrefix?: string
/** Largest diff, or file read to show unchanged lines, in bytes. */
maxDiffBytes?: number
/** Most untracked files a local diff or the local changes card includes. */
maxUntrackedFiles?: number
/** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */
maxCachedDiffBytes?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-forge -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp -->
<a id="deepseek-aidsh-experimental-computer-use-cua-driver-mcp"></a>
@@ -1066,6 +1104,68 @@ export type Config = CuaConfig
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-cua-macos -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge -->
<a id="deepseek-aidsh-experimental-forge"></a>
## `@deepseek-ai/dsh-experimental-forge`
- `source`: [`packages/experimental/forge/src/types.ts:205`](../packages/experimental/forge/src/types.ts)
```ts config-catalog
/** Poll cadence and fetch deadline for watched change requests. */
export interface Config {
/** Poll interval in milliseconds while checks run or mergeability is unknown. */
activeIntervalMs?: number
/** Poll interval in milliseconds for a closed request or an open request whose checks have settled. */
idleIntervalMs?: number
/** Largest delay in milliseconds between polls after consecutive failures. */
maxBackoffMs?: number
/** Deadline in milliseconds for one provider fetch. */
fetchTimeoutMs?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge-github -->
<a id="deepseek-aidsh-experimental-forge-github"></a>
## `@deepseek-ai/dsh-experimental-forge-github`
- `inject`: `forge`
- `source`: [`packages/experimental/forge-github/src/index.ts:14`](../packages/experimental/forge-github/src/index.ts)
```ts config-catalog
/** GitHub CLI executable, served hosts, and invocation limits. */
export interface Config {
/** GitHub CLI executable name or absolute host path. */
ghCommand?: string
/** Host names this provider serves; list GitHub Enterprise hosts explicitly. */
hosts?: string[]
/** Deadline in milliseconds for one `gh` invocation. */
timeoutMs?: number
/** Maximum captured bytes per `gh` output stream. */
maxOutputBytes?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge-github -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge-session -->
<a id="deepseek-aidsh-experimental-forge-session"></a>
## `@deepseek-ai/dsh-experimental-forge-session`
- `inject`: `forge` · `sessions` · `sessionProjections`
- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts)
```ts config-catalog
/** Deployment settings of `forgeSession`. */
export interface Config {
/** Deadline in milliseconds for one change request lookup after a push. */
discoveryTimeoutMs?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge-session -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-inspector -->
<a id="deepseek-aidsh-experimental-inspector"></a>
@@ -1398,7 +1498,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official'
## `@deepseek-ai/dsh-experimental-worktree`
- `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy`
- `source`: [`packages/experimental/worktree/src/index.ts:19`](../packages/experimental/worktree/src/index.ts)
- `source`: [`packages/experimental/worktree/src/index.ts:21`](../packages/experimental/worktree/src/index.ts)
```ts config-catalog
/** Worktree location, generated names, executables, and subprocess resource limits. */
@@ -4869,6 +4969,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) |
| `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) |
| `@deepseek-ai/dsh-experimental-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/src/index.ts) |
| `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) |
+102 -1
View File
@@ -1029,6 +1029,44 @@ export interface CotTranslationPreferences {
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-cot-translation -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-forge -->
<a id="deepseek-aidsh-experimental-client-ui-forge"></a>
## `@deepseek-ai/dsh-experimental-client-ui-forge`
- `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents`
- `source`: [`packages/experimental/client-ui-forge/src/types.ts:318`](../packages/experimental/client-ui-forge/src/types.ts)
```ts config-catalog
/** Host settings of the forge panel Remote. */
export interface Config {
/** Git executable name or absolute path in the Session's execution world. */
gitCommand?: string
/** Deadline in milliseconds for one Git command, including confinement. */
gitTimeoutMs?: number
/** Termination grace period in milliseconds for each Git process. */
graceMs?: number
/** Maximum captured bytes per Git output stream. */
maxOutputBytes?: number
/** Deadline in milliseconds for one provider sign-in probe. */
readinessTimeoutMs?: number
/** Most local branches listed as worktree bases. */
maxBranches?: number
/**
* Prefix of the branch and checkout directory created for a Session worktree; the first eight
* hexadecimal digits of the Session id's UUID, a hyphen, and four random hexadecimal digits follow it.
*/
worktreePrefix?: string
/** Largest diff, or file read to show unchanged lines, in bytes. */
maxDiffBytes?: number
/** Most untracked files a local diff or the local changes card includes. */
maxUntrackedFiles?: number
/** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */
maxCachedDiffBytes?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-forge -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp -->
<a id="deepseek-aidsh-experimental-computer-use-cua-driver-mcp"></a>
@@ -1068,6 +1106,68 @@ export type Config = CuaConfig
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-cua-macos -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge -->
<a id="deepseek-aidsh-experimental-forge"></a>
## `@deepseek-ai/dsh-experimental-forge`
- `source`: [`packages/experimental/forge/src/types.ts:205`](../packages/experimental/forge/src/types.ts)
```ts config-catalog
/** Poll cadence and fetch deadline for watched change requests. */
export interface Config {
/** Poll interval in milliseconds while checks run or mergeability is unknown. */
activeIntervalMs?: number
/** Poll interval in milliseconds for a closed request or an open request whose checks have settled. */
idleIntervalMs?: number
/** Largest delay in milliseconds between polls after consecutive failures. */
maxBackoffMs?: number
/** Deadline in milliseconds for one provider fetch. */
fetchTimeoutMs?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge-github -->
<a id="deepseek-aidsh-experimental-forge-github"></a>
## `@deepseek-ai/dsh-experimental-forge-github`
- `inject`: `forge`
- `source`: [`packages/experimental/forge-github/src/index.ts:14`](../packages/experimental/forge-github/src/index.ts)
```ts config-catalog
/** GitHub CLI executable, served hosts, and invocation limits. */
export interface Config {
/** GitHub CLI executable name or absolute host path. */
ghCommand?: string
/** Host names this provider serves; list GitHub Enterprise hosts explicitly. */
hosts?: string[]
/** Deadline in milliseconds for one `gh` invocation. */
timeoutMs?: number
/** Maximum captured bytes per `gh` output stream. */
maxOutputBytes?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge-github -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge-session -->
<a id="deepseek-aidsh-experimental-forge-session"></a>
## `@deepseek-ai/dsh-experimental-forge-session`
- `inject`: `forge` · `sessions` · `sessionProjections`
- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts)
```ts config-catalog
/** Deployment settings of `forgeSession`. */
export interface Config {
/** Deadline in milliseconds for one change request lookup after a push. */
discoveryTimeoutMs?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-forge-session -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-inspector -->
<a id="deepseek-aidsh-experimental-inspector"></a>
@@ -1400,7 +1500,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official'
## `@deepseek-ai/dsh-experimental-worktree`
- `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy`
- `source`: [`packages/experimental/worktree/src/index.ts:19`](../packages/experimental/worktree/src/index.ts)
- `source`: [`packages/experimental/worktree/src/index.ts:21`](../packages/experimental/worktree/src/index.ts)
```ts config-catalog
/** Worktree location, generated names, executables, and subprocess resource limits. */
@@ -4871,6 +4971,7 @@ export interface Config {
| `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) |
| `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) |
| `@deepseek-ai/dsh-experimental-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/src/index.ts) |
| `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) |
+7 -6
View File
@@ -18,8 +18,8 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -47,6 +47,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` |
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -67,8 +68,8 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
@@ -83,7 +84,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
@@ -105,7 +106,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) |
| `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) |
| `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
| `internal/service` | - | `gateway` |
| `internal/service` | - | `client-ui-forge`, `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
+7 -6
View File
@@ -20,8 +20,8 @@
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -49,6 +49,7 @@
| `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` |
| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -69,8 +70,8 @@
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
@@ -85,7 +86,7 @@
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
@@ -107,7 +108,7 @@
| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) |
| `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) |
| `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
| `internal/service` | - | `gateway` |
| `internal/service` | - | `client-ui-forge`, `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
@@ -6,5 +6,5 @@
en: a11f106978c9dce7
zh: a11f106978c9dce7
/experimental-plugin-record-catalog:
en: cb931db3b4bcbe33
zh: aca9cfecfdee34c0
en: 2e4c95aeb435f35e
zh: 669270a7e95ad6f2
+2
View File
@@ -14,5 +14,7 @@ Shared Session event declarations and resolved schemas appear in the [Session pe
| Record | Owner | Purpose | Payload and source |
|---|---|---|---|
| `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | <code>ActionCardDecision</code> · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) |
| `plugin:client-ui-forge/worktree` | `@deepseek-ai/dsh-experimental-client-ui-forge` | A request, withdrawal, or creation of the worktree a Session starts in. | <code>WorktreeRecord</code> · [`packages/experimental/client-ui-forge/src/types.ts`](../packages/experimental/client-ui-forge/src/types.ts) |
| `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | <code>BindingRecord</code> · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) |
| `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | <code>TranslationRequestRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
| `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | <code>TranslationResultRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
@@ -14,5 +14,7 @@
| 记录 | 所属包 | 用途 | 载荷与源码 |
|---|---|---|---|
| `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | <code>ActionCardDecision</code> · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) |
| `plugin:client-ui-forge/worktree` | `@deepseek-ai/dsh-experimental-client-ui-forge` | A request, withdrawal, or creation of the worktree a Session starts in. | <code>WorktreeRecord</code> · [`packages/experimental/client-ui-forge/src/types.ts`](../packages/experimental/client-ui-forge/src/types.ts) |
| `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | <code>BindingRecord</code> · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) |
| `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | <code>TranslationRequestRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
| `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | <code>TranslationResultRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
+24
View File
@@ -274,12 +274,17 @@ flowchart TD
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"]
pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"]
pkg_experimental_client_ui_forge["experimental-client-ui-forge"]
pkg_experimental_client_ui_stats["experimental-client-ui-stats"]
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"]
pkg_experimental_cua_macos["experimental-cua-macos"]
pkg_experimental_forge["experimental-forge"]
pkg_experimental_forge_bundle["experimental-forge-bundle"]
pkg_experimental_forge_github["experimental-forge-github"]
pkg_experimental_forge_session["experimental-forge-session"]
pkg_experimental_inspector["experimental-inspector"]
pkg_experimental_inspector_profile["experimental-inspector-profile"]
pkg_experimental_liveshare_protocol["experimental-liveshare-protocol"]
@@ -521,6 +526,8 @@ flowchart TD
pkg_hmr --> pkg_cmdline
pkg_deliverable_file_references --> pkg_system_prompt
pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use
pkg_experimental_forge_github --> pkg_experimental_forge
pkg_experimental_forge_github --> pkg_subprocess
pkg_experimental_webworker_runtime --> pkg_client_connection
pkg_experimental_webworker_runtime --> pkg_client_modules
pkg_experimental_webworker_runtime --> pkg_host_webserver
@@ -959,6 +966,11 @@ flowchart TD
pkg_experimental_browser_use_stagehand_native --> pkg_browser_use
pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt
pkg_experimental_browser_use_stagehand_native --> pkg_tools
pkg_experimental_client_ui_forge --> pkg_experimental_forge
pkg_experimental_client_ui_forge --> pkg_experimental_worktree
pkg_experimental_client_ui_forge --> pkg_fs
pkg_experimental_client_ui_forge --> pkg_session
pkg_experimental_client_ui_forge --> pkg_timeout
pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use
pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt
pkg_experimental_computer_use_cua_driver_native --> pkg_tools
@@ -1116,6 +1128,13 @@ flowchart TD
pkg_experimental_auto_review --> pkg_session
pkg_experimental_auto_review --> pkg_tools
pkg_experimental_auto_review --> pkg_working_directory
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_sandbox_policy
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_forge_session --> pkg_shell
pkg_experimental_forge_session --> pkg_shell_env
pkg_experimental_forge_session --> pkg_tools
pkg_webhook --> pkg_agent
pkg_webhook --> pkg_agent_default_model
pkg_webhook --> pkg_agent_preset_registry
@@ -1591,6 +1610,8 @@ flowchart TD
| [`experimental-client-ui-stats`](../packages/experimental/client-ui-stats) | `experimental` | — |
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — |
| [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — |
| [`experimental-forge`](../packages/experimental/forge) | `experimental` | — |
| [`experimental-forge-bundle`](../packages/experimental/forge-bundle) | `experimental` | — |
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — |
| [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — |
| [`experimental-liveshare-protocol`](../packages/experimental/liveshare-protocol) | `experimental` | — |
@@ -1653,6 +1674,7 @@ flowchart TD
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) |
| [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) |
| [`experimental-forge-github`](../packages/experimental/forge-github) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`subprocess`](../packages/subprocess/subprocess) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
@@ -1762,6 +1784,7 @@ flowchart TD
| [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
| [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) |
@@ -1793,6 +1816,7 @@ flowchart TD
| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) |
| [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+24
View File
@@ -276,12 +276,17 @@ flowchart TD
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"]
pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"]
pkg_experimental_client_ui_forge["experimental-client-ui-forge"]
pkg_experimental_client_ui_stats["experimental-client-ui-stats"]
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"]
pkg_experimental_cua_macos["experimental-cua-macos"]
pkg_experimental_forge["experimental-forge"]
pkg_experimental_forge_bundle["experimental-forge-bundle"]
pkg_experimental_forge_github["experimental-forge-github"]
pkg_experimental_forge_session["experimental-forge-session"]
pkg_experimental_inspector["experimental-inspector"]
pkg_experimental_inspector_profile["experimental-inspector-profile"]
pkg_experimental_liveshare_protocol["experimental-liveshare-protocol"]
@@ -523,6 +528,8 @@ flowchart TD
pkg_hmr --> pkg_cmdline
pkg_deliverable_file_references --> pkg_system_prompt
pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use
pkg_experimental_forge_github --> pkg_experimental_forge
pkg_experimental_forge_github --> pkg_subprocess
pkg_experimental_webworker_runtime --> pkg_client_connection
pkg_experimental_webworker_runtime --> pkg_client_modules
pkg_experimental_webworker_runtime --> pkg_host_webserver
@@ -961,6 +968,11 @@ flowchart TD
pkg_experimental_browser_use_stagehand_native --> pkg_browser_use
pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt
pkg_experimental_browser_use_stagehand_native --> pkg_tools
pkg_experimental_client_ui_forge --> pkg_experimental_forge
pkg_experimental_client_ui_forge --> pkg_experimental_worktree
pkg_experimental_client_ui_forge --> pkg_fs
pkg_experimental_client_ui_forge --> pkg_session
pkg_experimental_client_ui_forge --> pkg_timeout
pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use
pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt
pkg_experimental_computer_use_cua_driver_native --> pkg_tools
@@ -1118,6 +1130,13 @@ flowchart TD
pkg_experimental_auto_review --> pkg_session
pkg_experimental_auto_review --> pkg_tools
pkg_experimental_auto_review --> pkg_working_directory
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_sandbox_policy
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_forge_session --> pkg_shell
pkg_experimental_forge_session --> pkg_shell_env
pkg_experimental_forge_session --> pkg_tools
pkg_webhook --> pkg_agent
pkg_webhook --> pkg_agent_default_model
pkg_webhook --> pkg_agent_preset_registry
@@ -1593,6 +1612,8 @@ flowchart TD
| [`experimental-client-ui-stats`](../packages/experimental/client-ui-stats) | `experimental` | — |
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — |
| [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — |
| [`experimental-forge`](../packages/experimental/forge) | `experimental` | — |
| [`experimental-forge-bundle`](../packages/experimental/forge-bundle) | `experimental` | — |
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — |
| [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — |
| [`experimental-liveshare-protocol`](../packages/experimental/liveshare-protocol) | `experimental` | — |
@@ -1655,6 +1676,7 @@ flowchart TD
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) |
| [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) |
| [`experimental-forge-github`](../packages/experimental/forge-github) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`subprocess`](../packages/subprocess/subprocess) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
@@ -1764,6 +1786,7 @@ flowchart TD
| [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
| [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) |
@@ -1795,6 +1818,7 @@ flowchart TD
| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) |
| [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+2 -2
View File
@@ -3,5 +3,5 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
/subsystems:
en: 1d4818dbb07391a7
zh: f1e27ce66c181295
en: dcbb2f834453e166
zh: d73f32a7baeec42d
+1
View File
@@ -20,6 +20,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events |
| [working-directory.md](working-directory.md) | Session execution directories, durable changes, recovery, and user context |
| [worktrees.md](worktrees.md) | Experimental Git branch and checkout creation under existing permissions |
| [forge.md](forge.md) | Experimental code host change requests: provider contract, shared status polling, and Session bindings |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, the JSONL provider, `session/flush`, crash recovery, `SessionHeader` |
| [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits |
| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers |
+1
View File
@@ -20,6 +20,7 @@
| [session.md](session.zh.md) | 完整的 `SessionEventMap` 变体目录、`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 |
| [working-directory.md](working-directory.zh.md) | Session 执行目录、持久化变更、恢复与用户上下文 |
| [worktrees.md](worktrees.zh.md) | 现有权限下的实验性 Git 分支与检出目录创建 |
| [forge.md](forge.zh.md) | 实验性代码托管变更请求:提供方契约、共享状态轮询与 Session 绑定 |
| [persistence.md](persistence.zh.md) | 持久性 seam:`SessionPersistence`、JSONL 提供方、`session/flush`、崩溃恢复、`SessionHeader` |
| [settings.md](settings.zh.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 |
| [credentials.md](credentials.zh.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、提供方来源层 |
+22
View File
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for forge.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/forge.md
/forges:
en: 1b51f9d385dafe76
zh: 4bb73627aa69d652
/forges/providers-and-change-requests:
en: 4ec7d1892b4067db
zh: 6bb70d21ae3d7a89
/forges/status-polling:
en: 26ccfa2e342e3e67
zh: fe6cc2a622757c48
/forges/change-request-actions:
en: 00e4490e416099d2
zh: 5724d08b6ae230f7
/forges/session-bindings:
en: 13dfd36f2166bba0
zh: a369434f52f04d57
/forges/panel:
en: 9a84a26d97cb46f0
zh: 5e1e03b7714bcbae
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -15,8 +15,8 @@
en: b7b8a7b411aee94d
zh: 7298c20000945d97
/right-sidebar/navigation-ctx-sidebarright:
en: 0f80b5364cbad804
zh: db81652a5374fad1
en: c695e9aca92271f2
zh: 4b4d820c5a16bed1
/right-sidebar/slots-and-owner-props:
en: f58dc6ff6b0027a7
zh: 707722fcbd096efc
+1 -1
View File
@@ -88,7 +88,7 @@ Two opens are the navigation controller, and every way into the column calls one
Placement is the caller's option, never a type's property. The conversation calls `openResource(fileAddressFor(sessionId, cwd, path))` and, from a `read` tool row, adds `{ params: { line } }` from the call's 1-based `offset`; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`; the strip's add control calls `openTab('guide', { paneId, revealIfOpened: false })`.
`close(tabId)` closes a tab; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws.
`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws.
`focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import.
+1 -1
View File
@@ -88,7 +88,7 @@ export function apply(ctx: Context): void {
落位是调用方的选项,从不是类型的属性。会话区调 `openResource(fileAddressFor(sessionId, cwd, path))`,`read` 工具行另加 `{ params: { line } }`(来自调用的 1 起 `offset`);引导页入口框调 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的行调 `tab.actions.openResource(address)`;tab 条的新增控件调 `openTab('guide', { paneId, revealIfOpened: false })`。
`close(tabId)` 关闭一个 tab;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。
`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。
`focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。

Some files were not shown because too many files have changed in this diff Show More