Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

This commit is contained in:
Chinesezjc
2026-07-27 17:29:59 +08:00
70 changed files with 1436 additions and 117 deletions
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-25-workspace-ui-product-flow.md: a02087235a36f2c257de407facf2dc02ed072f3b
2026-07-25-workspace-ui-product-flow.zh.md: 8ccbf5b98401bef9c3fd40e948d35ec5f0818202
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md
2026-07-25-workspace-ui-product-flow.md: b8e1ec1efe19127cad8a12405dddeec38a4ff91e
2026-07-25-workspace-ui-product-flow.zh.md: b80b75a80671e9aa2ab59ff72c44c18a8ec5c16e
@@ -21,10 +21,11 @@ The Host provides the following GUI wiring on the Workspace entity:
| `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation |
| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict |
| `workspace.create({ path })` | Adopts an existing directory and does not create an arbitrary path |
| `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped |
| `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it |
| `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session |
`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting.
`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md).
A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly.
@@ -51,7 +52,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo
Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message.
Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Rename, Delete, moving across Workspaces, drag-and-drop ordering, manual adoption from Ungrouped, and separate display-name and directory-name inputs are outside this iteration's scope.
Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow.
### First send and recovery
@@ -75,6 +76,8 @@ A frontend Session Intent appears as a “New session” row and temporarily cou
Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order.
Deleting a Workspace registration removes its group without deleting or closing any Session. Its accounted Sessions immediately join Ungrouped, including the current Session; a reload reconstructs the same result from the independent Workspace and Session baselines.
### React and slot boundaries
React components only consume `useSessions`, `useWorkspaces`, and session-scoped hooks; they do not own entity lifecycles. The Zustand store retains only layout, the current view, composer text for ordinary real Sessions, and other purely presentational state. Session and Workspace Intents, materialization phases, errors, and retained prompts reside in the React-free runtime object layer.
@@ -106,6 +109,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo
- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front.
- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts.
- Both the UI and Host reject duplicate Workspace names; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped.
- Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback.
- Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order.
## Consequences
@@ -21,10 +21,11 @@ Host 在 Workspace entity 上提供以下 GUI 接线:
| `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id |
| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 |
| `workspace.create({ path })` | 收编已经存在的目录,不为任意路径创建目录 |
| `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped |
| `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach |
| `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session |
`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,Client 重连后分别刷新 `workspace.list``session.list` 基线。
`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`Client 重连后分别刷新 `workspace.list``session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。
Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace pathSessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。
@@ -51,7 +52,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预
顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。
Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。Rename、Delete、跨 Workspace 移动、拖拽排序、Ungrouped 手动收编显示名/目录名双输入不在本期范围。
Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名目录名仍不在此动线范围
### 首次发送与恢复
@@ -75,6 +76,8 @@ Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定
无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added``workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。
删除 Workspace 注册记录会移除其分组,但不会删除或关闭任何 Session。已记账的 Session(包括当前 Session)会立即进入 Ungrouped;刷新后,独立的 Workspace 与 Session 基线会重建出相同结果。
### React 与 slot 边界
React 组件只消费 `useSessions``useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。
@@ -106,6 +109,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe
- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。
- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。
- UI 与 Host 两层拒绝同名 Workspacecwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。
- 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。
- keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。
## Consequences
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
2026-07-27-workspace-registration-deletion.md: 8168b0832ca39e6023f6981815ffe758b5695361
2026-07-27-workspace-registration-deletion.zh.md: b0df6982ac81426a5b0ce2f0e2b0e744212e3f5b
@@ -0,0 +1,59 @@
# Agent Note: Workspace Registration Deletion
Status: implemented
English | [中文](2026-07-27-workspace-registration-deletion.zh.md)
## Problem
A Workspace registers an existing code directory so the GUI can name it and order its Sessions. That record has no reliable provenance proving that Harness created or owns the directory, and the Session log is an independent persistence object. Treating the row's Delete action as recursive source deletion or Session deletion would destroy data outside the record's ownership boundary.
The existing visual-only menu row also left deletion semantics undefined across durable order, the Workspace table, Host streams, concurrent browser tabs, reconnect baselines, and a list request racing the mutation.
## Decision
`ctx.workspace.delete(id)` deletes only the Workspace registration: its id leaves durable `workspaceIds`, its `workspaces` table row and entity-cache entry disappear, and its ordered `sessionIds` account disappears with that row. It never calls filesystem removal or `SessionPersistence`; the directory, every user file, every live Session, and every persisted Session log remain. Because sidebar grouping is the complement of all surviving Workspace accounts, those Sessions immediately appear under Ungrouped, including the current Session.
Unknown ids return `false` at the domain seam. `workspace.delete({ workspaceId })` maps that distinction to `workspace-not-found`; success returns `{ deleted: true }`. `workspace.list` remains the reconnect baseline.
## Durable commit and publication
Registry operations serialize create and delete. Deletion first writes the Workspace order without the id, then removes the entity from the cache, then deletes the table row. The table deletion is the notification commit point: the package invariant accepts it only after the cache stopped publishing the entity, and the Host emits `host/workspace-removed` only from that committed deletion. A table-write failure restores the cache and prior durable order; no removal frame is published.
The Host stream keeps its committed-id set through the preceding global-order write and removes the id only on the table deletion. Create rollback therefore emits no false removal, while every connected tab receives exactly the id needed to delete its projection.
Create and delete write a durable `pendingMutation` before their record/order pair can diverge. Startup completes only the named create or delete and clears the marker; it never infers crash provenance from an orphan row alone. Unmarked order/table divergence therefore retains the registry's fail-loud corruption behavior. A deletion whose table write committed but marker cleanup failed still reports success—the requested state and removal frame are already committed—and the next startup clears that marker idempotently.
## Client convergence
`WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta.
The delete confirmation remains pending until the React Workspace projection has committed the removed id, so the next create gesture cannot observe one stale list frame. During create, duplicate-name validation is suppressed while the request is pending because the committed `host/workspace-changed` frame may publish the newly created Workspace before its unary response; after failure returns the form to editing, validation uses the latest list again.
## Confirmation interaction
The existing Workspace row menu opens a shared `Modal` before deletion. The text states all three consequences: the Workspace leaves the list, the folder and session logs remain, and its Sessions appear under Ungrouped. While the request is pending, the confirm and Cancel controls are disabled, duplicate confirmation is ignored, and Escape or Close cannot dismiss the operation. Failure keeps the Modal open with the error; Cancel, Escape, and Close before submission never delete.
The menu, Modal, and buttons retain their existing structure and design tokens. Session deletion remains visual-only and outside this decision.
## Alternatives considered
**Cascade-delete Sessions.** Rejected because Workspace registration does not own Session persistence and the product requirement is to preserve histories under Ungrouped. Session deletion needs its own lifecycle, running checks, descendant semantics, and explicit UI.
**Move the folder to Trash.** Rejected because the record cannot prove directory ownership. A future destructive filesystem action must be separately named, separately confirmed, and enforce explicit safety boundaries.
**Delete the table row and repair order later.** Rejected because a crash or write failure would leave an initialized registry whose order and table disagree. The registry updates both under one serialized operation and restores the prior order on table failure.
**Delete every unreferenced row at startup.** Rejected because the same shape can come from unexplained order corruption; silently discarding it could lose Workspace metadata and Session accounting. Recovery requires the explicit pending marker written by the owning mutation.
**Refetch both lists after success.** Rejected because the committed removal frame plus immediate unary echo is sufficient, preserves the current Session object, and avoids turning a local mutation into two list requests. Reconnect baselines remain the repair path.
## Verification
Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, projection-settled closing, pending-state duplicate suppression, success-frame-before-unary ordering, failure, Cancel, Escape, and Close. The browser scenario observes every transient alert, slot error, console error, and page error while reusing a deleted title for a different directory.
The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload.
## Consequences
Deleting a Workspace is intentionally reversible by registering the same directory again with a fresh id, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns.
@@ -0,0 +1,59 @@
# Agent Noteagent 决策记录):删除 Workspace 注册记录
Status: implemented
[English](2026-07-27-workspace-registration-deletion.md) | 中文
## Problem
Workspace 注册已有代码目录,使 GUI 能够为目录命名,并对其会话排序。该记录没有可靠的来源信息来证明 Harness 创建或拥有该目录,会话日志也是独立的持久化对象。若将行内 Delete 操作视为递归删除源码或删除会话,就会破坏该记录所有权边界之外的数据。
现有菜单行仅提供视觉效果,因此持久顺序、Workspace 表、Host 流、并发浏览器标签页、重连基线,以及列表请求与变更并发时的删除语义也没有定义。
## Decision
`ctx.workspace.delete(id)` 只删除 Workspace 注册记录:其 id 会从持久 `workspaceIds` 中移除,`workspaces` 表行与实体缓存条目会消失,有序 `sessionIds` 账本也随该行一并消失。它绝不调用文件系统移除操作或 `SessionPersistence`;目录、所有用户文件、所有实时会话和所有持久化会话日志都会保留。侧边栏分组是所有存续 Workspace 账本的补集,因此这些会话(包括当前会话)会立即出现在 Ungrouped 下。
未知 id 在 domain seam 返回 `false``workspace.delete({ workspaceId })` 将该结果映射为 `workspace-not-found`;成功时返回 `{ deleted: true }``workspace.list` 仍是重连基线。
## 持久提交与发布
注册表操作会串行执行创建与删除。删除时先写入移除该 id 后的 Workspace 顺序,再从缓存中移除实体,最后删除表行。表删除是通知提交点:只有缓存停止发布该实体后,包不变量才接受该删除;Host 也只根据这次已提交的删除发出 `host/workspace-removed`。表写入失败时,系统会恢复缓存和此前的持久顺序,且不会发布移除帧。
Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合,只在删除表行时移除该 id。因此,创建回滚不会发出错误的移除帧,而每个已连接标签页都能收到从自身投影中删除该记录所需的准确 id。
Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendingMutation`。启动时只补全其中明确命名的 create 或 delete,并清除该标记;系统绝不会仅凭孤立表行的形状推断崩溃来源。因此,没有标记的顺序/表分叉仍会保持注册表原有的损坏直接失败语义。如果删除的表写入已经提交、但标记清理失败,操作仍会报告成功——请求状态和移除帧都已经提交——下一次启动会以幂等方式清除该标记。
## 客户端收敛
`WorkspaceManager``host/workspace-changed``host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。
删除确认框会保持待处理,直到 React Workspace 投影已经提交目标 id 的移除,因此下一次创建操作不会读到一帧陈旧列表。创建请求进行中会暂停重复名称校验,因为已提交的 `host/workspace-changed` 帧可能先于一元响应发布刚创建的 Workspace;如果请求失败并让表单回到可编辑状态,系统会重新使用最新列表执行校验。
## 确认交互
现有 Workspace 行菜单会在删除前打开共享 `Modal`。文案明确说明三项后果:Workspace 会从列表中移除,文件夹和会话日志会保留,相关会话会出现在 Ungrouped 下。请求待处理期间,确认与 Cancel 控件均被禁用,重复确认会被忽略,Escape 或 Close 也无法关闭此次操作。失败时 `Modal` 保持打开并显示错误;提交前使用 Cancel、Escape 或 Close 绝不会触发删除。
菜单、`Modal` 和按钮保留现有结构与设计 token。会话删除仍仅提供视觉效果,不在本决策范围内。
## Alternatives considered
**级联删除会话。** 不予采纳,因为 Workspace 注册记录不拥有会话持久化,且产品需求是将历史记录保留在 Ungrouped 下。会话删除需要自己的生命周期、运行状态检查、后代对象的处理语义和明确 UI。
**将文件夹移到废纸篓。** 不予采纳,因为该记录无法证明目录所有权。未来的破坏性文件系统操作必须使用单独名称、单独确认,并实施明确的安全边界。
**先删除表行,之后再修复顺序。** 不予采纳,因为崩溃或写入失败会使已初始化注册表的顺序与表不一致。注册表会在同一串行操作内更新二者,并在表操作失败时恢复此前顺序。
**启动时删除所有未引用表行。** 不予采纳,因为来源不明的顺序损坏也会呈现相同形状;静默丢弃可能损失 Workspace 元数据和 Session 账本。恢复必须依赖拥有该变更的操作预先写入的明确待处理标记。
**成功后重新拉取两个列表。** 不予采纳,因为已提交的移除帧与即时一元回显已足够,既能保留当前会话对象,也避免将局部变更扩大为两次列表请求。重连基线仍是修复路径。
## Verification
Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、待处理状态下抑制重复提交、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。
组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。
## Consequences
删除 Workspace 后仍可使用新 id 重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
2026-07-21-serial-cross-platform-ci-reference.md: 5eac1bc1c47c7309942b5615bc98a7fed893f346
2026-07-21-serial-cross-platform-ci-reference.zh.md: 35fb761023fe7be081bf7d9591a53ed98b6e3abc
2026-07-21-serial-cross-platform-ci-reference.md: 9ecd7906d2f28461a8a6f3f2fe485580214b39f3
2026-07-21-serial-cross-platform-ci-reference.zh.md: ed927012c5b0ba9d43444a129d10d966f5e7d923
@@ -14,15 +14,15 @@ Reviewers also need a direct answer to a simpler question: what happens when the
## Decision
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
Platform ownership remains explicit inside that complete aggregate. `pty-local` supports Linux and macOS and therefore owns its unit and per-file coverage contract on POSIX rather than loading a backend that rejects `win32`; the Windows run still executes every portable package. Portable fixtures derive native paths through `node:path`, compare canonical identities with the same native realpath implementation as production, and use filenames legal on every host. ACP snapshot runs also pass both JavaScript and native realpath spellings of their generated cwd to the normalizer, which replaces aliases longest-first so Windows short and long paths cannot churn shared fixtures.
The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
@@ -14,15 +14,15 @@ Status: implemented
## 决策
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行个显式参考作业,名称分别为 `serial / linux``serial / macos``serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行个显式参考作业:在标准托管运行器上的 `serial / linux``serial / macos``serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci``DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci``DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS,因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端;Windows 仍会执行所有可移植包(package)。可移植 fixture(测试前置数据)通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACPAgent Client Protocol)快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。
macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest``macos-latest``windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-22-evidence-based-larger-hosted-runners.md: fe11e6929545923d27fbf41f5a39f7dd2b9c3fbf
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 47879284532a537cbe7e78aa2c495c4ef0be26c4
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
2026-07-22-evidence-based-larger-hosted-runners.md: 67fc7ded5cffc6a219665f135a4c9e1cc4752691
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 71c5c067361b57fab5aae9e9ffa3850a30609db3
@@ -52,6 +52,8 @@ The process-bound coverage project contains exactly five suite files. Thirty-two
Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch.
An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records.
## Alternatives considered
**Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute.
@@ -52,6 +52,8 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完
只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。
另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写者可管理的仓库变量 `DSH_CI_FAILOVER` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基线分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。
## 曾考虑的替代方案
**保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
2026-07-26-ci-failover-runbook.md: 4e4f8ea7fc60cf76fd8308147bbf7cc0bac74798
2026-07-26-ci-failover-runbook.zh.md: bb7e43fe55c9cced51f042de6503978ec9349d6b
@@ -0,0 +1,50 @@
# Agent Note: CI failover runbook — hosted pools → in-house pool
Status: implemented
English | [中文](2026-07-26-ci-failover-runbook.zh.md)
## Problem
The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`, `windows`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`, and only the Windows leg has no in-house substitute at all — during the 2026-07-27 outage the standard pools recovered first, which is the ordering this design bets on. An outage therefore needs a switch any responder with repository write access can throw without merging anything.
## Decision
Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push.
### What the in-house pool is
`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity.
### Switch (any repository writer, ~1 minute, no merge)
1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`.
2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
3. That is the entire switch. Under failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs).
#**Dependabot exception.** All four selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VM. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers.
**Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so the switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner group admits all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VM by pushing a branch workflow. The boundary against untrusted code is repository membership; the variable only routes work for members.
## Capacity during failover
Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files**`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; only a started service adds capacity. About a minute per instance.
### Switch back
Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhosted`). New runs resolve back to the hosted enterprise pools. Remove any extra instances that were registered during the incident.
### Trust boundary
The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the four failover jobs are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.
## Alternatives considered
**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is writer-manageable state that takes effect on re-run without a merge.
**Keep the self-hosted pool always in the required path.** Rejected because it trades hosted-pool availability for the in-house VM's, moving a single point of failure rather than adding a fallback. The variable keeps the hosted pools primary and the self-hosted pool a proven, one-action standby.
## Consequences
Recovering from a hosted-pool outage is a single variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg.
@@ -0,0 +1,50 @@
# Agent Note: CI 故障切换手册 — 托管池 → 自有池
Status: implemented
[English](2026-07-26-ci-failover-runbook.md) | 中文
## 问题
[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static``node 24 / coverage``node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障。**判定作业的其余必需依赖(`node-compat``python-sdk``windows`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`,且只有 Windows 这条腿完全没有自有替代——2026-07-27 的故障中标准池率先恢复,本设计押注的正是这一顺序。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。
## 决策
三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,coverage 与 snapshot 的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。
### 自有池是什么
`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。
### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并)
1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`
2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏 6 × 8 = 48 个覆盖率工作进程对 64 核)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。
#**Dependabot 例外。**四个选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖方提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。
**谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此该开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成越权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。
## 切换期间的容量
6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;只有启动了服务的 runner 才会增加容量。每个约一分钟。
### 切回
删除 `DSH_CI_FAILOVER` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回托管企业池。若故障期间追加注册过实例,将其移除。
### 信任边界
该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——四个故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。
## 曾考虑的替代方案
**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是写者可管理的状态,重跑即生效,无需合并。
**让自托管池长期处于必需路径中。** 否决,因为这是拿托管池的可用性去换自有虚拟机的可用性,只是搬移了单点故障而非增加回退。该变量让托管池保持主路径,自托管池作为一个经过验证、一步即可启用的热备。
## 后果
从托管池故障中恢复只需一个变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f
2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md
2026-07-24-domain-kv-storage-and-workspace.md: 230877628428dc88dbddeecfe5f4353cf15e151d
2026-07-24-domain-kv-storage-and-workspace.zh.md: 050f72cd3327f83e2c3f3cefcab63c01e8f112ee
@@ -11,7 +11,9 @@ The host's only persistence surface is the session event log (`packages/session-
- **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned).
- **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates.
Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code.
Separately, Session deletion needs a `SessionPersistence` delete primitive and a `session.delete` endpoint. That gap's design is settled in this note, but its implementation remains future work.
The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) supersedes only that coupling: deleting a Workspace registration preserves its Sessions and their logs, while Session deletion remains separate future work. The cascade design below is therefore not the Workspace GUI delete semantic.
## Proposal
@@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service {
get(id: WorkspaceId): Workspace | undefined
list(): Workspace[]
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
// deletefuture work(与 session 级联删一起做,见下);本期不提供任何删除入口
delete(id: WorkspaceId): Promise<boolean> // 只删注册记录;目录与 session 日志保留
}
```
- **Path canon**: the stored value = `fs.realpath(input)` (trailing slashes, `..`, and symlinks all resolved); uniqueness = string equality after normalization (a symlink resolving to the same directory counts as a collision). A missing directory makes create reject outright (realpath fails — a workspace must point at an existing directory; "Create new = make the directory" is upper-layer interaction: mkdir first, then create). The session cwd in attach checks follows the same canon. Single-valued cwd + unique path ⇒ one session structurally belongs to at most one workspace; double bookkeeping is impossible on the write side.
- **Title**: a display name, defaults to `basename(path)`, mutable, duplicates allowed. Ownership is never derived from cwd as a fallback — cwd cannot express ordering, and ownership is a workspace-side fact; sessions started headless belong to no workspace.
- Consumers see only the `Workspace` interface; `WorkspaceEntity` stays inside the package (a single implementation does not pre-split a seam). Entities are unique per id (registry cache); the record snapshot is swapped in place after each write, and the outside sees getters only. Every write funnels through the entity's internal `mutate(fn)` → `table.update`, with `updatedAt` refreshed inside mutate. Domain objects never cross RPC; next phase the wire layer projects records into zod wire schemas.
- **Workspace deletion is future work as a whole** (settled 2026-07-24): the registry ships no delete method this phase — the half-measure "delete the record, keep the sessions" is not exposed; deletion and the session cascade (`recursive` parameter, running checks, bottom-up order, crash-rerun convergence) land as one complete semantic together with the session delete primitive; the order then is delete sessions one by one → prune the ledger → delete the workspace record.
- **Session deletion remains future work.** The later [Workspace registration deletion decision](../../implemented/feature/2026-07-27-workspace-registration-deletion.md) ships `ctx.workspace.delete(id)` as a metadata-only operation that preserves Sessions and logs. Recursive Session deletion, running checks, and crash-rerun convergence belong to a separate `session.delete` capability.
Consistency doctrine (the ledger = the only ownership authority; the implementation and test baseline):
@@ -285,7 +287,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha
| Not doing | Trigger | Rework point | Groundwork |
| --- | --- | --- | --- |
| The full deletion suite (`SessionPersistence.delete`, the deleted event, `registry.delete` cascade, recursive delete, running checks) | future work starts (before the GUI needs delete interactions) | implement per the future-work section above: the session primitive + `registry.delete(id, { recursive? })` land as one | orchestration rules and rejection table settled in this note; no deletion entry exists this phase, so no half-semantics to stay compatible with |
| Session deletion (`SessionPersistence.delete`, the deleted event, recursive delete, running checks) | a destructive Session-delete product flow starts | implement the session primitive plus `session.delete`; keep it independent from Workspace registration deletion | orchestration rules and rejection table above remain groundwork; Workspace deletion preserves Sessions and logs |
| The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already |
| Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only |
| Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process |
@@ -296,7 +298,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha
| Cross-table atomic transactions | one business operation touching two tables of one domain atomically | `domain.transact(fn)`; JSON whole-unit rewrite is naturally atomic, SQLite wraps a transaction | — |
| Secondary indexes / conditional queries | in-memory filtering stops scaling (tens of thousands of records) | SQLite JSON1 over the value column, a read-only query facet on the seam | the JSON backend does not follow |
| Moving a session across workspaces | a product need appears | relax the attach check into a "detach first, then attach" orchestration | — |
| RPC/GUI/boot | next phase | `workspace.*` + `session.delete` endpoints, wire schemas, boot mounting, sidebar on real data | this phase's model and semantics are the direct source of the wire projection |
| Session-delete RPC/GUI | a destructive Session-delete product flow starts | `session.delete` endpoint, wire schema, and explicit confirmation UI | Workspace RPC/GUI is shipped separately; no cascade coupling remains |
## Alternatives considered
@@ -317,7 +319,7 @@ Snapshots: no model-visible or assembly surface this phase, none added; next pha
## Acceptance criteria
- This phase's four test suites all green: the shared backend contract suite on both json/sqlite, registry/mount disposer semantics, the domain layer (including the six open steps and fail-loud routing), and full workspace semantics (create/attach checks/consistency doctrine).
- `ctx.workspace` completes the create → attach → list lifecycle under a test assembly (deletion is future work).
- `ctx.workspace` completes the create → attach → list → metadata-only delete lifecycle under a test assembly.
- Zero diff in the session-persistence packages (the acceptance line for not touching the session side this phase).
- No new snapshots this phase (no model-visible or assembly surface); added next phase with the RPC wiring.
@@ -11,7 +11,9 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session-persis
- **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。
- **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。
另外,workspace 删除最终需要删除其关联 session,而 `SessionPersistence` 没有删除原语host 也没有 `session.delete` 端点——该空白的设计随本 Note 定案,但实施标记为 future work:本期不动 session 侧任何代码
另外,Session 删除需要 `SessionPersistence` 删除原语 `session.delete` 端点该空白的设计随本 Note 定案,但实现仍属未来工作
后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)取代的仅是上述耦合关系:删除 Workspace 注册记录会保留相关 Session 及其日志,Session 删除仍是独立的未来工作。因此,下文的级联设计并不是 Workspace GUI 的删除语义。
## Proposal
@@ -234,14 +236,14 @@ export class WorkspaceRegistry extends Service {
get(id: WorkspaceId): Workspace | undefined
list(): Workspace[]
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
// deletefuture work(与 session 级联删一起做,见下);本期不提供任何删除入口
delete(id: WorkspaceId): Promise<boolean> // 只删注册记录;目录与 session 日志保留
}
```
- **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 rejectrealpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create)。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace,双重记账写侧不可能。
- **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实;headless 直开的 session 不属于任何 workspace。
- 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(registry 缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update``updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。
- **workspace 删除整体为 future work**2026-07-24 拍板):本期 registry 不提供 delete 方法——半截的"只删记录session"语义不对外暴露,删除与 session 级联(`recursive` 参数、运行中检查、自底向上、崩溃重跑收敛)作为一个完整语义随 session 删除原语一起落地;届时顺序为逐个删 session → 摘账 → 删记录
- **Session 删除仍属未来工作。** 后续的 [Workspace 注册记录删除决策](../../implemented/feature/2026-07-27-workspace-registration-deletion.md)已将 `ctx.workspace.delete(id)` 作为仅删除元数据、保Session 与日志的操作交付。递归删除 Session、运行中检查和崩溃重跑收敛属于独立的 `session.delete` 能力
一致性口径(账 = 归属唯一依据;实现与测试基准):
@@ -285,7 +287,7 @@ export class WorkspaceRegistry extends Service {
| 不做 | 触发条件 | 返工点 | 预埋 |
| --- | --- | --- | --- |
| 删除全套`SessionPersistence.delete`、deleted 事件、`registry.delete` 级联、递归删、运行中检查) | future work 启动(GUI 需要删除交互前) | 按上文 future work 节实施:session 原语 + `registry.delete(id, { recursive? })` 一体落地 | 编排规则/拒绝清单已定案在本 Note;本期无任何删除入口,无半截语义要兼容 |
| Session 删除(`SessionPersistence.delete`、deleted 事件、递归删、运行中检查) | 破坏性的 Session 删除产品流启动 | 实现 Session 原语 `session.delete`;与 Workspace 注册记录删除保持独立 | 上文编排规则拒绝清单仍是基础;Workspace 删除会保留 Session 与日志 |
| `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 |
| 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 |
| 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence | 进程内已有 `domain/changed` |
@@ -296,7 +298,7 @@ export class WorkspaceRegistry extends Service {
| 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`JSON 天然原子,SQLite 包事务 | — |
| 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 |
| session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — |
| RPC/GUI/boot | 下期 | `workspace.*` + `session.delete` 端点、wire schema、boot 挂载、sidebar 接真数据 | 本期模型与语义即 wire 投影的直接来源 |
| Session 删除 RPCGUI | 破坏性的 Session 删除产品流启动 | `session.delete` 端点、wire schema 与明确的确认 UI | Workspace RPCGUI 已独立交付,不再存在级联耦合 |
## Alternatives considered
@@ -317,7 +319,7 @@ export class WorkspaceRegistry extends Service {
## Acceptance criteria
- 测试矩阵本期四套件全绿:backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。
- `ctx.workspace` 可在测试组装下完成 create → attach → list 生命周期(删除为 future work
- `ctx.workspace` 可在测试组装下完成 create → attach → list → 仅删除元数据的 delete 生命周期。
- session-persistence 包零 diff(本期不动 session 侧的验收线)。
- 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。
+103 -8
View File
@@ -30,9 +30,24 @@ jobs:
# Three enterprise jobs isolate coverage, static analysis, and the
# build-backed consumer tail. The static job publishes its exact build so
# consumers do not repeat the longest part of their critical path.
#
# FAILOVER: each Linux enterprise job resolves its pool through the
# DSH_CI_FAILOVER repository variable. Unset (normal), the expressions
# pick the hosted enterprise pools below. Setting the variable to
# 'selfhosted' (repo Settings → Actions → Variables; writer-manageable
# repository state — not PR-editable, no merge required) retargets all
# three onto the in-house
# vm-backup pool and re-running the failed jobs is the entire switch —
# see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The
# in-house pool's readiness is re-proven on every master push by the
# serial-linux-selfhosted standby lane below.
node-24:
if: github.event_name == 'pull_request'
runs-on: dsh-enterprise-ubuntu-latest-32core-test
runs-on: >-
${{ vars.DSH_CI_FAILOVER == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'dsh-enterprise-ubuntu-latest-32core-test' }}
name: node 24 / static
env:
DSH_GATE_CONCURRENCY: '8'
@@ -44,8 +59,10 @@ jobs:
persist-credentials: false
# Pull requests consume the default-branch cache but do not put cache
# compression and upload on the paid latency-critical path.
# compression and upload on the paid latency-critical path. Skipped
# under failover — see the coverage lane's identical rationale.
- uses: actions/cache/restore@v4
if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: /home/runner/.local/share/pnpm/store/v11
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
@@ -81,17 +98,30 @@ jobs:
node-24-coverage:
if: github.event_name == 'pull_request'
runs-on: dsh-enterprise-ubuntu-24-04-32core-test
runs-on: >-
${{ vars.DSH_CI_FAILOVER == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'dsh-enterprise-ubuntu-24-04-32core-test' }}
name: node 24 / coverage
env:
DSH_COVERAGE_MAX_WORKERS: '24'
# Failover shrinks the worker bound: the hosted 32-core runner is
# exclusive to one job, but the failover pool shares one 64-core VM
# across six always-on runner instances, and the timing-sensitive
# process suites have documented aggregate-contention failures.
# 8 × 6 instances = 48 workers worst case on 64 cores.
DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '24' }}
DSH_GATE_CONCURRENCY: '8'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
# Skipped under failover: the self-hosted VM's persistent pnpm store
# serves warm installs directly, and this hosted-path restore would
# spend ~52 s pulling ~180 MB into a path pnpm never reads there.
- uses: actions/cache/restore@v4
if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: /home/runner/.local/share/pnpm/store/v11
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
@@ -122,7 +152,11 @@ jobs:
node-24-consumers:
needs: node-24
if: github.event_name == 'pull_request'
runs-on: dsh-enterprise-ubuntu-latest-32core-test
runs-on: >-
${{ vars.DSH_CI_FAILOVER == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'dsh-enterprise-ubuntu-latest-32core-test' }}
name: node 24 / snapshots and artifacts
env:
DSH_ESLINT_CACHE: '1'
@@ -130,7 +164,8 @@ jobs:
DSH_GATE_CONCURRENCY: '8'
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
DSH_PUBLINT_CONCURRENCY: '8'
DSH_SNAPSHOT_MAX_CONCURRENCY: '32'
# Failover halves snapshot concurrency for the shared 64-core VM.
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
steps:
- uses: actions/checkout@v6
with:
@@ -144,7 +179,9 @@ jobs:
- name: Restore built tree
run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
# Skipped under failover — see the coverage lane's identical rationale.
- uses: actions/cache/restore@v4
if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: /home/runner/.local/share/pnpm/store/v11
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
@@ -555,6 +592,56 @@ jobs:
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
run: pnpm run check:ci
# Hot-standby drill for the in-house self-hosted pool: every master move
# re-runs the complete unsharded aggregate on the persistent 64-core VM,
# continuously proving that environment can take over a required lane if
# the hosted pools degrade (the switch is then setting the writer-manageable
# DSH_CI_FAILOVER variable — see the failover runbook, no merge required).
# Push-triggered, so this lane always executes the base branch's own
# workflow definition. (Under failover, pull_request jobs do reach these
# runners with the PR merge ref's workflow — the boundary there is
# repository membership: private, forking disabled, Dependabot excluded.)
# Non-blocking for
# pull requests; no cache steps because the VM's persistent pnpm store and
# tool caches make them redundant (and saving here would poison the hosted
# cache namespace with self-hosted paths).
serial-linux-selfhosted:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
name: serial / linux (self-hosted standby)
runs-on: [self-hosted, linux, x64, vm-backup]
steps:
# Full history + DSH_ARCHIVE_BASE_REF below: same frozen-archive
# comparison as serial-linux. Depth 2 would miss github.event.before
# on multi-commit or force pushes; full fetch is cheap here because
# checkout resolves against the VM's local mirror.
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Enable corepack (pnpm)
run: corepack enable
- name: Install (immutable)
run: pnpm install --frozen-lockfile
- name: Prepare bubblewrap (unrestrict userns)
run: bash scripts/prepare-ci-bubblewrap.sh
- name: Run complete unsharded primary Node CI serially
env:
DSH_ARCHIVE_BASE_REF: ${{ github.event.before }}
DSH_COVERAGE_MAX_WORKERS: '1'
DSH_E2E_MAX_WORKERS: '1'
DSH_ESLINT_CACHE: '1'
DSH_GATE_CONCURRENCY: '1'
DSH_PUBLINT_CONCURRENCY: '1'
DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
run: pnpm run check:ci
serial-macos:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
name: serial / macos
@@ -876,8 +963,16 @@ jobs:
# 'cancelled' and 'skipped'.
all-checks-passed:
name: all checks passed
# This bookkeeping-only verdict must not depend on custom-pool provisioning.
runs-on: ubuntu-latest
# This bookkeeping-only verdict must not depend on custom-pool
# provisioning (master's #734 moved it to ubuntu-latest) — and under
# failover it follows the same selector as the worker jobs it
# aggregates, so a standard-hosted outage cannot strand the
# branch-protection verdict either (observed 2026-07-27).
runs-on: >-
${{ vars.DSH_CI_FAILOVER == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'ubuntu-latest' }}
needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
if: always() && github.event_name == 'pull_request'
steps:
+213 -7
View File
@@ -5,12 +5,13 @@
// calls: workspace.create/rename are host RPCs with no model involvement,
// and the one session row the flat/hover scenarios need comes from a seeded
// fixture (the seeded-history seed reused verbatim — no new recording).
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
@@ -105,6 +106,203 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete'))
const slotConsoleErrors: string[] = []
const transientSlotErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
slotConsoleErrors.push(message.text())
}
})
await page.exposeFunction('recordDshSlotError', (key: string) => {
if (!transientSlotErrors.includes(key)) transientSlotErrors.push(key)
})
await page.evaluate(() => {
const target = window as unknown as { recordDshSlotError(key: string): Promise<void> }
const seen = new Set<string>()
const collect = (): void => {
for (const node of document.querySelectorAll<HTMLElement>('[data-slot-error]')) {
const key = node.dataset.slotError ?? ''
if (!seen.has(key)) {
seen.add(key)
void target.recordDshSlotError(key)
}
}
}
new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
collect()
})
// Register the scaffold's existing project directory through the real UI.
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' })
await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd)
await useFolder.getByRole('button', { name: 'Use folder' }).click()
await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0)
const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
await workspace.attachSession(SessionId(SEED_ID))
const header = (await scaffold.ctx.sessionPersistence.list())
.find(candidate => candidate.id === SEED_ID)
if (header === undefined) throw new Error('seeded Session log disappeared before deletion')
const logLocation = scaffold.ctx.sessionPersistence.locate(header)
if (logLocation === undefined) throw new Error('JSONL persistence did not expose the seeded log path')
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
await stat(logLocation.path)
// Open the seeded (first/accounted) Session so deletion must preserve the
// current selection while it moves into Ungrouped.
const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first()
await groupRow.waitFor({ timeout: 10_000 })
const groupSection = groupRow.locator('..')
if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click()
await expect.poll(
() => groupSection.locator('[role="treeitem"]').count(),
{ timeout: 10_000 },
).toBeGreaterThanOrEqual(2)
const seededRow = groupSection.locator('[role="treeitem"]').nth(1)
await seededRow.click()
await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
await groupRow.hover()
await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click()
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Delete workspace' })
await dialog.waitFor({ timeout: 10_000 })
const copy = await dialog.textContent()
expect(copy).toContain('workspace list')
expect(copy).toContain('folder and session logs will be kept')
expect(copy).toContain('sessions will appear under Ungrouped')
await dialog.getByRole('button', { name: 'Delete workspace' }).click()
await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0)
expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined()
await expect.poll(
() => page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).count(),
{ timeout: 10_000 },
).toBe(0)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
.toBeGreaterThanOrEqual(1)
await expect.poll(
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
{ timeout: 10_000 },
).toBe(1)
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
await stat(logLocation.path)
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
// Re-registering the exact deleted path immediately, without a reload, is
// a supported reversible flow. It creates a fresh Workspace id without
// re-adopting the retained Session.
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' })
await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd)
await reuseFolder.getByRole('button', { name: 'Use folder' }).click()
await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0)
const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
expect(reregistered?.id).toBeDefined()
expect(reregistered?.id).not.toBe(workspace.id)
expect(reregistered?.sessionIds).toEqual([])
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
.toBeGreaterThanOrEqual(1)
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
await stat(logLocation.path)
// Restore the deleted-registry state so reload still verifies deletion
// persistence independently of the successful re-registration above.
if (reregistered === undefined) throw new Error('same-path re-registration did not materialize')
await scaffold.ctx.workspace.delete(reregistered.id)
await expect.poll(
() => page.getByRole('button', { name: `Workspace actions for ${reregistered.title}` }).count(),
{ timeout: 10_000 },
).toBe(0)
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 })
.toBeGreaterThanOrEqual(1)
await expect.poll(
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
{ timeout: 15_000 },
).toBe(1)
expect(scaffold.ctx.workspace.get(workspace.id)).toBeUndefined()
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
await stat(logLocation.path)
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
expect(transientSlotErrors).toEqual([])
expect(slotConsoleErrors).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('reuses a deleted title for a different new directory without any transient error surface', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-reuse-title'))
const title = 'same-name'
const oldPath = join(scaffold.workspaceCwd, 'adopted', title)
await mkdir(oldPath, { recursive: true })
const transientErrors: string[] = []
const consoleErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
await page.exposeFunction('recordDshTransientWorkspaceError', (message: string) => {
if (!transientErrors.includes(message)) transientErrors.push(message)
})
await page.evaluate(() => {
const target = window as unknown as {
recordDshTransientWorkspaceError(message: string): Promise<void>
}
const collect = (): void => {
for (const node of document.querySelectorAll<HTMLElement>('[data-slot-error], [role="alert"]')) {
const message = node.dataset.slotError ?? node.textContent?.trim() ?? ''
if (message !== '') void target.recordDshTransientWorkspaceError(message)
}
}
new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
collect()
})
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
const adopt = page.getByRole('dialog', { name: 'Use an existing folder' })
await adopt.getByLabel('Existing folder path').fill(oldPath)
await adopt.getByRole('button', { name: 'Use folder' }).click()
await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0)
const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath)
if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered')
const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first()
await oldRow.hover()
await page.getByRole('button', { name: `Workspace actions for ${title}` }).click()
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
await page.getByRole('dialog', { name: 'Delete workspace' })
.getByRole('button', { name: 'Delete workspace' }).click()
await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined()
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const create = page.getByRole('dialog', { name: 'Create a new workspace' })
await create.getByLabel('New workspace name').fill(title)
await create.getByRole('button', { name: 'Create workspace' }).click()
await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0)
const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title)
expect(fresh?.id).toBeDefined()
expect(fresh?.id).not.toBe(oldWorkspace.id)
expect(fresh?.path).toBe(join(scaffold.workspaceCwd, title))
expect(transientErrors).toEqual([])
expect(consoleErrors).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('switches to the flat "In one list" view and persists the preference', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat'))
// Grouped default: workspace group rows render (the seeded session sits
@@ -134,12 +332,20 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Expand Ungrouped to reveal the seeded session row, then dwell on it
// (the card opens after a 500ms hover delay, portaled to body).
await page.getByText('Ungrouped', { exact: true }).click()
// A cold summary carries no durable title, so the row falls back to a
// cwd-derived display title — anchored on the run-local workspace-root
// basename rather than a literal.
const wsBase = scaffold.workspaceCwd.split('/').pop()!
const sessionRow = page.locator('[role="treeitem"]').filter({ hasText: wsBase }).first()
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
const ungroupedSection = ungroupedRow.locator('..')
// Initial-current auto-expansion can race this following test's gesture;
// converge on expanded rather than assuming which update wins first.
await expect.poll(async () => {
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
await page.waitForTimeout(50)
}
return await ungroupedRow.getAttribute('aria-expanded')
}, { timeout: 5_000 }).toBe('true')
// The only visible child is the non-blank persisted Session; the blank
// Session created while adopting the Workspace remains hidden.
const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.hover()
// Card content: the full title plus the Idle status line (display-only
+10
View File
@@ -2039,6 +2039,16 @@ get(id: WorkspaceId): Workspace | undefined
*/
list(): Workspace[]
/**
* Delete one workspace registration while retaining its directory and every
* session log. The durable order is updated before the table deletion; a
* failed table write restores the prior order and keeps the entity
* published. Unknown ids are an idempotent no-op for domain callers.
* @param id - Workspace registration to remove.
* @returns `true` when a record was deleted, `false` when it was unknown.
*/
delete(id: WorkspaceId): Promise<boolean>
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned
@@ -753,6 +753,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
}
return ok(request, { workspace: { ...workspace } })
},
delete: (request) => {
const { workspaceId } = request.payload
const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
if (index === -1) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${workspaceId}`,
details: { workspaceId },
})
}
workspaces.splice(index, 1)
emitHost({ type: 'host/workspace-removed', workspaceId })
return ok(request, { deleted: true as const })
},
insertSessionBefore: (request) => {
const { workspaceId, sessionId, beforeSessionId } = request.payload
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
@@ -944,6 +958,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)
case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'command.list': return this.api.commands.list(request)
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
@@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient {
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
@@ -384,6 +384,31 @@ describe('createFixtureApi', () => {
expect(noop.result.value.workspace.updatedAt).toBe(before)
})
it('workspace.delete removes only the Workspace row and emits the removal frame', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
await consuming
expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }])
const list = await api.workspace.list(req({}))
if (!list.result.ok) throw new Error('workspace list failed')
expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false)
const sessions = await api.sessions.list(req({}))
if (!sessions.result.ok) throw new Error('session list failed')
expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha')
})
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
const api = createFixtureApi()
const abort = new AbortController()
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: d7b7bbc4e4e05893689a8f2dcac82763b4c67ef8
README.zh.md: d2054170cd6f31505793812fff46cc0f2356ad75
README.md: a434b2d5719de2f30a883ee6e0d26264b3c62f4e
README.zh.md: a79fa99578e6bce4f0ff8e7c1f7538df8a436778
+3 -1
View File
@@ -6,7 +6,9 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a
## Workspace and Session lists
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
+3 -1
View File
@@ -6,7 +6,9 @@
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
@@ -19,6 +19,10 @@ export interface WorkspaceListSnapshot {
error: RpcError | null
}
type WorkspaceDelta =
| { type: 'upsert'; workspace: WorkspaceView }
| { type: 'remove'; workspaceId: WorkspaceId }
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
export class WorkspaceManager {
private items: Workspace[] = []
@@ -28,7 +32,16 @@ export class WorkspaceManager {
private phase: WorkspaceListPhase = 'pending'
private error: RpcError | null = null
private inflight: Promise<void> | null = null
private refreshFrames: WorkspaceView[] | null = null
private refreshFrames: WorkspaceDelta[] | null = null
/**
* Ids this process has seen removed, kept for the connection's lifetime so
* a late changed frame or a stale baseline row cannot resurrect a deleted
* row. Correctness rests on Host ids never being reused (the registry mints
* a fresh `randomUUID` per record, including when the same directory is
* registered again) — a path-derived id scheme would turn these entries
* into permanent blindfolds and must clear them instead.
*/
private readonly removedIds = new Set<WorkspaceId>()
private snapshotCache: WorkspaceListSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
@@ -51,7 +64,7 @@ export class WorkspaceManager {
this.state = 'loading'
this.error = null
const established = this.itemViews()
const frames: WorkspaceView[] = []
const frames: WorkspaceDelta[] = []
this.refreshFrames = frames
this.notifier.markDirty()
this.inflight = (async () => {
@@ -61,7 +74,8 @@ export class WorkspaceManager {
let items = this.phase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
for (const workspace of frames) items = upsertWorkspace(items, workspace)
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
this.installViews(items)
this.state = 'idle'
this.phase = 'ready'
@@ -111,6 +125,18 @@ export class WorkspaceManager {
return result
}
/**
* Delete a Workspace registration and remove its local projection from the
* unary response without waiting for the Host frame.
* @param workspaceId - target workspace.
* @returns the wire result.
*/
async delete(workspaceId: WorkspaceId): Promise<RpcResult<{ deleted: true }>> {
const { result } = await this.api.workspace.delete({ workspaceId })
if (result.ok) this.remove(workspaceId, true)
return result
}
/**
* Move a session within its Workspace's manual order, then publish the
* returned snapshot without waiting for the changed frame.
@@ -139,6 +165,7 @@ export class WorkspaceManager {
*/
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
}
/** Re-pull the baseline after each connection generation. */
@@ -175,7 +202,8 @@ export class WorkspaceManager {
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
this.refreshFrames?.push(view)
if (this.removedIds.has(view.workspaceId)) return
this.refreshFrames?.push({ type: 'upsert', workspace: view })
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
// Mutation responses and changed frames race (two carriers, no ordering):
// reject a snapshot strictly older than the installed projection so a
@@ -195,6 +223,24 @@ export class WorkspaceManager {
this.notifier.markDirty()
}
/** Remove one id idempotently and retain a tombstone against late echoes. */
private remove(workspaceId: WorkspaceId, direct = false): void {
this.refreshFrames?.push({ type: 'remove', workspaceId })
this.removedIds.add(workspaceId)
const items = this.items.filter(item =>
item.getSnapshot().view?.workspaceId !== workspaceId)
if (items.length === this.items.length) {
// The Host frame may have removed the row first but left its batched
// notification pending. A successful unary echo still flushes that
// committed state before the user action resolves.
if (direct) this.notifier.notifyNow()
return
}
this.items = items
if (direct) this.notifier.notifyNow()
else this.notifier.markDirty()
}
private installViews(views: readonly WorkspaceView[]): void {
const existing = new Map(
this.items.flatMap((workspace) => {
@@ -234,3 +280,10 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
? [workspace, ...items]
: items.map((item, position) => position === index ? workspace : item)
}
/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
return delta.type === 'upsert'
? upsertWorkspace(items, delta.workspace)
: items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
}
@@ -174,6 +174,16 @@ export class WorkspacesService {
return result.value.workspace
}
/**
* Delete one Workspace registration. Sessions, session logs, and the
* directory remain Host-owned outside this operation.
* @param workspaceId - target workspace.
*/
async delete(workspaceId: WorkspaceId): Promise<void> {
const result = await this.manager.delete(workspaceId)
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
* @param workspaceId - owning workspace.
@@ -96,6 +96,9 @@ export class FakeApiClient implements IApiClient {
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
() => Promise.resolve(ok({ deleted: true }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
@@ -103,6 +106,7 @@ export class FakeApiClient implements IApiClient {
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
insertSessionBefore: (payload: unknown) =>
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
}
@@ -76,6 +76,48 @@ describe('WorkspaceManager', () => {
ok: false, error: { code: 'internal', message: 'create transport' },
})
})
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const hydration = manager.refresh()
manager.handleHostEnvelope({
rpcId: 'removed' as never,
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
})
gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] }))
await hydration
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
manager.handleHostEnvelope({
rpcId: 'late-change' as never,
payload: { type: 'host/workspace-changed', workspace: workspace('gone') },
})
manager.handleHostEnvelope({
rpcId: 'duplicate-remove' as never,
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
})
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
})
it('removes from the unary delete echo while a refresh is in flight', async () => {
const api = new FakeApiClient()
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] }))
const manager = new WorkspaceManager(api)
await manager.refresh()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const refresh = manager.refresh()
await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }])
expect(manager.getSnapshot().items).toEqual([])
gate.resolve(ok({ items: [workspace('gone')] as never[] }))
await refresh
expect(manager.getSnapshot().items).toEqual([])
})
})
describe('WorkspacesService', () => {
@@ -175,4 +217,20 @@ describe('WorkspacesService', () => {
}))
await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
await workspaces.refresh()
await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
expect(workspaces.list.getSnapshot().items).toEqual([])
api.onWorkspaceDelete = () => Promise.resolve(err({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
}))
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
})
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33
README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259
README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951
+2 -2
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Workspace rename/delete controls** — the picker supports selection and creation only.
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.
+2 -2
View File
@@ -4,7 +4,7 @@
共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -18,5 +18,5 @@
## 已知限制与暂缓事项
- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session
- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。
@@ -258,6 +258,16 @@
color: var(--dsw-alias-state-error-primary);
}
.deleteAction:not(:disabled) {
color: var(--dsw-alias-state-error-primary);
}
.deleteStatus {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
@media (prefers-reduced-motion: reduce) {
.wide {
animation: none;
@@ -87,10 +87,15 @@ type SessionTreeProps = Pick<
query: string
/** Open the browser-owned rename dialog for a real Workspace group. */
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
function SessionTree({
useSessions, startSession, open, workspaces, query,
onRenameRequest, onDeleteRequest, insertSessionBefore,
}: SessionTreeProps) {
const list = useSessions((s) => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
@@ -128,11 +133,17 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
onRename={group.workspaceId === undefined
actions={group.workspaceId === undefined
? undefined
: () => {
/* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
: {
rename: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
},
delete: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
},
}}
/>
{group.sessions.map((node, index) => {
@@ -236,6 +247,7 @@ export function WorkspaceBrowser({
startSession,
open,
renameWorkspace,
deleteWorkspace,
insertSessionBefore,
createWorkspace,
}: WorkspaceBrowserProps) {
@@ -291,6 +303,41 @@ export function WorkspaceBrowser({
})
}
// Delete dialog is separate from the row so a successful removal can
// unmount that row without tearing down the in-flight confirmation state.
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteCommittedId, setDeleteCommittedId] = useState<WorkspaceId | null>(null)
const [deleteError, setDeleteError] = useState<string | null>(null)
useEffect(() => {
if (deleteCommittedId === null
|| workspaces.some(workspace => workspace.workspaceId === deleteCommittedId)) return
setDeleting(false)
setDeleteCommittedId(null)
setDeleteTarget(null)
}, [deleteCommittedId, workspaces])
const closeDelete = () => {
if (deleting) return
setDeleteTarget(null)
setDeleteError(null)
}
const confirmDelete = () => {
/* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */
if (deleting || deleteTarget === null) return
setDeleting(true)
setDeleteCommittedId(null)
setDeleteError(null)
deleteWorkspace(deleteTarget.workspaceId).then(() => {
// Keep the confirmation pending until this component has rendered the
// committed list projection without the deleted id. Closing earlier
// exposes one stale React frame to the next Create Workspace gesture.
setDeleteCommittedId(deleteTarget.workspaceId)
}).catch((reason: unknown) => {
setDeleting(false)
setDeleteError(reason instanceof Error ? reason.message : String(reason))
})
}
return (
<div className={clsx(css.root, !wide && css.rail)}>
<div className={css.sectionHeader}>
@@ -382,6 +429,10 @@ export function WorkspaceBrowser({
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
/>
))}
</div>
@@ -416,6 +467,30 @@ export function WorkspaceBrowser({
)}
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
</Modal>
<Modal
open={deleteTarget !== null}
onClose={closeDelete}
title="Delete workspace"
{...deleteTarget === null
? {}
: { description: `This removes “${deleteTarget.title}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.` }}
footer={(
<>
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
<Button
variant="outline"
className={css.deleteAction!}
disabled={deleting}
onClick={confirmDelete}
>
Delete workspace
</Button>
</>
)}
>
{deleting && <div className={css.deleteStatus} role="status">Deleting workspace</div>}
{deleteError !== null && <div className={css.renameError} role="alert">{deleteError}</div>}
</Modal>
</div>
)
}
@@ -60,7 +60,7 @@ export function WorkspaceCreateFlow({
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = normalizedWorkspaceName !== ''
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
const items: MenuEntry[] = [
@@ -32,6 +32,8 @@ export type WorkspaceBrowserInjected = {
open: (sessionId: SessionId) => void
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
/**
* Reorder a session inside its Workspace account (DOM-insertBefore
* semantics: omitted anchor appends to the end). The view refreshes from
@@ -39,6 +39,7 @@ export function apply(ctx: ClientContext): void {
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
@@ -3,7 +3,7 @@
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename; the session hover card is suppressed while a menu
* is open.
* is open. Workspace Rename/Delete are wired; session actions remain visual-only.
*/
import { useState } from 'react'
import clsx from 'clsx'
@@ -39,12 +39,12 @@ const WORKSPACE_MENU_ITEMS = [
* @param props.onCreate - start a frontend Session inside this Workspace.
* @returns the row element.
*/
export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
/** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */
onRename?: (() => void) | undefined
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
actions?: { rename: () => void; delete: () => void } | undefined
}) {
const row = group
const active = group.expanded && group.containsCurrent
@@ -68,15 +68,19 @@ export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
{onRename !== undefined && (
{actions !== undefined && (
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={WORKSPACE_MENU_ITEMS}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename()
// Delete is visual-only for now.
// Unknown ids leave before the dispatch: a future menu row must
// not inherit the destructive branch as an else fallback.
/* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */
if (id !== 'rename' && id !== 'delete') return
if (id === 'rename') actions.rename()
else actions.delete()
}}
portal
closeOnPointerLeave
@@ -98,12 +98,16 @@ describe('workspace browser rows', () => {
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
const onRename = vi.fn()
const onDelete = vi.fn()
const onToggle = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />)
render(<ProjectRowItem
group={group} onToggle={onToggle} onCreate={vi.fn()}
actions={{ rename: onRename, delete: onDelete }}
/>)
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
// Opening the menu neither toggles the group nor renames yet.
expect(onToggle).not.toHaveBeenCalled()
@@ -111,11 +115,11 @@ describe('workspace browser rows', () => {
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
expect(onRename).toHaveBeenCalledOnce()
expect(screen.queryByRole('menu')).toBeNull()
// Delete stays visual-only: selecting it just closes the menu.
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledOnce()
expect(onDelete).toHaveBeenCalledOnce()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.keyDown(document, { key: 'Escape' })
@@ -54,6 +54,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
startSession: vi.fn(),
open: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
...overrides,
@@ -457,6 +458,79 @@ describe('WorkspaceBrowser', () => {
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
})
it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => {
let resolveDelete!: () => void
const deleteWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveDelete = resolve }))
const browser = mount({
useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
deleteWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
const dialog = screen.getByRole('dialog', { name: 'Delete workspace' })
expect(dialog.textContent).toContain('removes “Alpha” from the workspace list')
expect(dialog.textContent).toContain('folder and session logs will be kept')
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(deleteWorkspace).toHaveBeenCalledOnce()
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
expect(confirm.disabled).toBe(true)
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
await act(async () => { resolveDelete() })
// RPC success alone does not close: the component waits until its
// useWorkspaces projection has committed the removal, preventing a stale
// duplicate-name frame from leaking into the next create gesture.
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
rerender(browser, { useWorkspaces: hook(workspaceState([])) })
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
})
it('keeps the delete dialog open on failure and allows retry or cancellation', async () => {
const deleteWorkspace = vi.fn()
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockRejectedValueOnce('denied')
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
deleteWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') })
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
})
it('Cancel, Escape, and Close dismiss deletion without calling the action', () => {
const deleteWorkspace = vi.fn(async () => {})
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
deleteWorkspace,
})
const open = () => {
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
}
open()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
open()
fireEvent.keyDown(document, { key: 'Escape' })
open()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(deleteWorkspace).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
})
it('search hides drag affordances (rows are not draggable during search)', () => {
const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
mount({
@@ -35,18 +35,25 @@ function anchor(): { current: HTMLElement } {
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
const onPick = vi.fn()
const onClose = vi.fn()
const view = render(
const anchorRef = anchor()
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
<WorkspacePicker
open
anchorRef={anchor()}
anchorRef={anchorRef}
useSessions={hook(sessions)}
useWorkspaces={hook(workspaceState(items))}
useWorkspaces={hook(workspaceState(nextItems))}
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
/>,
/>
)
return { view, onPick, onClose, createWorkspace }
const view = render(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
@@ -106,6 +113,22 @@ describe('WorkspacePicker', () => {
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => {
let resolve!: (workspace: WorkspaceView) => void
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('fresh', 'same-name')
const b = mount([], vi.fn(() => pending))
chooseCreateItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
b.rerenderItems([created])
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
expect(screen.queryByRole('alert')).toBeNull()
await act(async () => { resolve(created); await pending })
expect(b.onPick).toHaveBeenCalledWith(created.workspaceId)
})
it('exposes creation phase and error text while retaining the modal for retry', async () => {
let reject!: (reason: unknown) => void
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
@@ -950,6 +950,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'list(): Workspace[]',
jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */',
},
{
signature: 'delete(id: WorkspaceId): Promise<boolean>',
jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */',
},
{
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */',
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 775ceec5b2171b81977650b739b5b94662902dd5
README.zh.md: d28d6cdad43b7c944f6bdf17e80e99494b39308f
README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1
README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020
+1 -1
View File
@@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
+1 -1
View File
@@ -12,7 +12,7 @@
mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed``host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
+21 -2
View File
@@ -777,6 +777,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return ok(request, { workspace: workspaceView(workspace) })
},
async delete(request) {
const { workspaceId } = request.payload
const operation = workspaceCreationChain.then(() =>
ctx.workspace.delete(brandWorkspaceId(workspaceId)))
workspaceCreationChain = operation.then(() => undefined, () => undefined)
if (!await operation) return workspaceNotFound(request, workspaceId)
return ok(request, { deleted: true as const })
},
async insertSessionBefore(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
@@ -990,8 +999,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
}),
ctx.on('domain/changed', (change) => {
if (change.domain !== 'workspace' || change.operation !== 'put') return
if (change.domain !== 'workspace') return
if (change.table === '') {
if (change.operation !== 'put') return
const state = workspaceDomainState.parse(change.value)
for (const workspaceId of state.workspaceIds) {
if (committedWorkspaceIds.has(workspaceId)) continue
@@ -1004,7 +1014,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
return
}
if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return
if (change.table !== 'workspaces') return
if (change.operation === 'deleted') {
if (!committedWorkspaceIds.delete(change.key)) return
queue.push(frame({
type: 'host/workspace-removed',
workspaceId: change.key as WorkspaceId,
}))
return
}
if (!committedWorkspaceIds.has(change.key)) return
// Existing-entity table writes are complete attach/touch commits.
// A new entity's first put waits for the global registry write above.
queue.push(frame({
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
import { workspaceViewSchema } from './workspace.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
export const askUserQuestionItemSchema = z.object({
@@ -47,6 +47,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
z.object({ type: z.literal('host/commands-changed') }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<HostFrame>
+4 -1
View File
@@ -85,7 +85,9 @@ export type MuxFrame =
* agent-error is the only outlet for live failures with no turn position;
* workspace-changed pushes the full new snapshot after every durable
* workspace mutation (create/attach/order change — the client upserts, while
* `workspace.list` provides the reconnect baseline).
* `workspace.list` provides the reconnect baseline); workspace-removed is the
* committed registration-deletion increment and never implies directory or
* session-log deletion.
*/
export type HostFrame =
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
@@ -93,6 +95,7 @@ export type HostFrame =
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
/**
* The command registry changed (`commands/change` passthrough). Pure
* invalidation signal, no payload: clients refetch `command.list` in the
@@ -26,6 +26,7 @@ export interface RpcMethodMap {
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']
'workspace.delete': WorkspaceApi['delete']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
'command.list': CommandsApi['list']
'command.execute': CommandsApi['execute']
@@ -59,6 +59,16 @@ export const workspaceRenameValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
/** workspace.delete request payload. */
export const workspaceDeleteRequestSchema = z.object({
workspaceId: workspaceIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.delete'>>>
/** workspace.delete response value. */
export const workspaceDeleteValueSchema = z.object({
deleted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
export const workspaceInsertSessionBeforeRequestSchema = z.object({
workspaceId: workspaceIdSchema,
@@ -65,6 +65,14 @@ export interface WorkspaceApi {
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView }>>
/**
* Removes one Workspace registration. The directory, every user file, and
* every session log remain untouched; those Sessions consequently become
* ungrouped. An unknown id fails with `workspace-not-found`.
*/
delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
Promise<RpcResponse<{ deleted: true }>>
/**
* Moves an accounted session within its workspace's manual order,
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
@@ -23,6 +23,7 @@ import {
} from '../api/sessions.schema.ts'
import {
workspaceCreateValueSchema,
workspaceDeleteValueSchema,
workspaceInsertSessionBeforeValueSchema,
workspaceListValueSchema,
workspaceRenameValueSchema,
@@ -60,6 +61,7 @@ export interface IApiClient {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
}
commands: {
@@ -91,6 +93,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
'workspace.delete': workspaceDeleteValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
'command.list': commandListValueSchema,
'command.execute': commandExecuteValueSchema,
@@ -285,6 +288,7 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
}
@@ -24,6 +24,7 @@ import {
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
workspaceDeleteRequestSchema,
workspaceInsertSessionBeforeRequestSchema,
workspaceListRequestSchema,
workspaceRenameRequestSchema,
@@ -57,6 +58,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
@@ -244,4 +244,37 @@ describe('Host Workspace increments', () => {
abort.abort()
expect(await next).toMatchObject({ done: true })
})
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
const { api, ctx } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
const sessionId = SessionId('session-kept-after-workspace-delete')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
const abort = new AbortController()
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const removed = nextHostFrame(stream)
expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId })))
expect(await removed).toMatchObject({
payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId },
})
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
expect(ctx.agents.get(sessionId)).toBeDefined()
expect(existsSync(workspace.path)).toBe(true)
const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))
expect(missing.result).toMatchObject({
ok: false,
error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
})
const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
expect(reregistered.path).toBe(workspace.path)
expect(reregistered.sessionIds).toEqual([])
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
abort.abort()
})
})
@@ -40,6 +40,7 @@ function scriptedApi(overrides: {
list: r => ok(r, { items: [] }),
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
delete: r => ok(r, { deleted: true as const }),
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
},
commands: {
@@ -76,13 +77,15 @@ describe('unary round trip', () => {
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
})
it('routes workspace rename and insertSessionBefore through the wire', async () => {
it('routes workspace rename, delete, and insertSessionBefore through the wire', async () => {
const api = scriptedApi()
const c = client(api)
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
expect(renamed.result.ok).toBe(true)
const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' })
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
const deleted = await c.workspace.delete({ workspaceId: 'w1' as never })
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
expect(anchored.result.ok).toBe(true)
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
@@ -64,6 +64,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
}
},
async delete(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } }
},
async insertSessionBefore(request) {
return {
rpcId: request.rpcId,
@@ -14,6 +14,7 @@ import {
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import {
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
workspaceListRequestSchema, workspaceListValueSchema,
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
@@ -177,6 +178,13 @@ describe('workspace domain schemas', () => {
expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
})
it('validates workspace deletion payload and receipt', () => {
expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1')
expect(() => workspaceDeleteRequestSchema.parse({})).toThrow()
expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true })
expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
})
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
@@ -273,6 +281,11 @@ describe('events frame schemas', () => {
{ type: 'host/session-removed', sessionId: 's' },
{ type: 'host/session-status', sessionId: 's', running: true },
{ type: 'host/agent-error', sessionId: 's', message: 'boom' },
{ type: 'host/workspace-changed', workspace: {
workspaceId: 'w', path: '/w', title: 'w', sessionIds: [],
createdAt: '0', updatedAt: '0',
} },
{ type: 'host/workspace-removed', workspaceId: 'w' },
{ type: 'host/commands-changed' },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
+3 -3
View File
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 0d5ebabfbbb2922a369adb3a5d67ea4aafbe700f
README.zh.md: b82e8e6138f3e97c3c047cf1812cee8e558ea29b
# pnpm run verify-translation-pairing --write packages/workspace/README.md
README.md: ba92e95d3cde0a95eaaeae5a9b4384c3b8c9c4b8
README.zh.md: 8c8146bba5fa6d81c0ce5d2ed29add77b4083270
+2 -2
View File
@@ -2,10 +2,10 @@
English | [中文](README.zh.md)
The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md).
| Package | Role | ctx key |
|---|---|---|
| `workspace/` | `WorkspaceRegistry` service over the storage domain form: realpath-unique paths, session-ownership accounting, entity cache | `ctx.workspace` |
Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deletion (workspace and session cascade) is deliberately absent this phase and ships with the session-side primitives.
Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deleting a Workspace removes only this registry record and account: directories, user files, and session logs remain, and the Sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)).
+2 -2
View File
@@ -2,10 +2,10 @@
[English](README.md) | 中文
Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。
Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `workspace/` | 位于存储领域形式之上的 `WorkspaceRegistry` 服务:按 realpath 唯一的路径、会话所有权计数、实体缓存 | `ctx.workspace` |
所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。本阶段有意不提供删除(workspace 与会话级联);该功能将与会话侧原语一起交付
所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。删除 Workspace 只会移除该注册表记录及账本:目录、用户文件和会话日志都会保留,相关会话则进入 Ungrouped(参见[决策记录](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 0d395ecc58fc5e3362cb5f3c565a0539bb09c4dd
README.zh.md: 017e1e4d3aae9f8708ead3565f8b5b59d9b249ca
# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62
README.zh.md: 7960a2d13df4f881687fd88cdb07e237b3abb7c8
+4 -1
View File
@@ -10,6 +10,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title.
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it.
- `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity.
- `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry.
- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes.
- `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup.
@@ -17,6 +18,8 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
`storageDomain` and `sessionPersistence` are required startup dependencies. An unavailable peer leaves the plugin pending and cannot commit an empty initialized marker. On the first successful start, the registry calls `SessionPersistence.list()` and uses only header `id`, `cwd`, and `createdAt` to group valid historical directories and persist initial order; it never reads event bodies. The initialized marker is written last, so partial bootstrap writes are reused safely after restart. Later cwd-only sessions remain Ungrouped.
Create and delete persist an explicit pending-mutation marker before their record and order can diverge. Startup completes only the marked mutation, then clears the marker; an unmarked order/table mismatch remains unexplained corruption and fails loud. Deleting and re-registering the same path creates a fresh Workspace id and does not automatically re-adopt the retained Sessions.
## Model Experience
### Workspace records and session accounts
@@ -35,5 +38,5 @@ Independent of live requests: the package never touches a request prefix, so it
## Known Limitations and Deferred Work
- No delete entry point in this phase — workspace deletion ships as one complete semantic together with the session-delete primitive and cascade orchestration (future-work section of the Agent Note); a half "drop the record, keep the sessions" operation is deliberately not exposed.
- Session deletion and destructive folder removal are separate, absent capabilities; Workspace registration deletion never substitutes for either ([decision](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)).
- The header index refreshes at startup and when attach must resolve an uncached persisted id; deletion or cwd damage performed by another process is observed after the next refresh or restart.
+4 -1
View File
@@ -10,6 +10,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径不能创建重复标题。
- `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它应用同一 `realpath` 规范,并会拒绝缺失路径,而不是创建路径。
- `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话账本。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、实时会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。
- `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。
- `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话为空操作,workspace 顺序绝不改变。
- `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、从两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。
@@ -17,6 +18,8 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
`storageDomain``sessionPersistence` 是启动必需依赖。对等服务不可用时,插件保持待处理,且不能提交空的已初始化标记。首次成功启动时,注册表调用 `SessionPersistence.list()`,仅使用头部 `id``cwd``createdAt` 对有效历史目录分组并持久化初始顺序;它绝不读取事件正文。已初始化标记最后写入,因此重启后可安全复用部分启动写入。后续仅有 cwd 的会话仍属于 Ungrouped。
Create 与 delete 会在记录和顺序可能分叉之前,先持久化明确的待处理变更标记。启动时只补全被该标记证明的变更,随后清除标记;没有标记的顺序/表不一致仍属于来源不明的损坏,并会直接失败。删除后重新注册同一路径会生成新的 Workspace id,且不会自动重新接纳保留下来的 Session。
## 模型体验
### Workspace 记录与会话记账
@@ -35,5 +38,5 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
## 已知限制与延后工作
- 本阶段没有删除入口:workspace 删除将与会话删除原语和级联编排一起作为完整语义交付(参见 Agent Note 的未来工作一节);系统有意不公开「删除记录、保留会话」的半成品操作
- 会话删除与破坏性的文件夹移除是彼此独立且尚未提供的功能;删除 Workspace 注册记录绝不能替代二者(参见[决策记录](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)
- 头部索引会在启动时刷新,也会在 attach 必须解析未缓存持久 id 时刷新;另一进程执行的删除或 cwd 破坏会在下次刷新或重启后被观测。
+109 -3
View File
@@ -109,6 +109,7 @@ export class WorkspaceRegistry extends Service {
this.global = domain.global
this.state = domain.global.get()
await this.recoverPendingMutation()
this.validateStoredState(this.state)
if (!this.state.initialized) {
const headers = await this.ctx.sessionPersistence.list()
@@ -168,6 +169,18 @@ export class WorkspaceRegistry extends Service {
})
}
/**
* Delete one workspace registration while retaining its directory and every
* session log. The durable order is updated before the table deletion; a
* failed table write restores the prior order and keeps the entity
* published. Unknown ids are an idempotent no-op for domain callers.
* @param id - Workspace registration to remove.
* @returns `true` when a record was deleted, `false` when it was unknown.
*/
delete(id: WorkspaceId): Promise<boolean> {
return this.enqueueOperation(() => this.deleteKnown(id))
}
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned
@@ -206,10 +219,28 @@ export class WorkspaceRegistry extends Service {
}
const entity = new WorkspaceEntity(this.host, id, record)
this.entities.set(id, entity)
const pendingState: WorkspaceDomainState = {
...state,
pendingMutation: { operation: 'create', workspaceId: id },
}
try {
await this.setState(pendingState)
} catch (error) {
this.entities.delete(id)
throw error
}
try {
await table.put(id, record)
} catch (error) {
this.entities.delete(id)
try {
await this.setState(state)
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`workspace '${id}' record write and pending-marker rollback both failed`,
)
}
throw error
}
@@ -220,10 +251,17 @@ export class WorkspaceRegistry extends Service {
try {
await table.delete(id)
} catch (rollbackError) {
this.entities.set(id, entity)
throw new AggregateError(
[error, rollbackError],
`workspace '${id}' was stored but its registry order and rollback both failed`,
`workspace '${id}' order write and record rollback both failed; the pending marker remains recoverable`,
)
}
try {
await this.setState(state)
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`workspace '${id}' order write and pending-marker rollback both failed`,
)
}
throw error
@@ -231,6 +269,69 @@ export class WorkspaceRegistry extends Service {
return entity
}
private async deleteKnown(id: WorkspaceId): Promise<boolean> {
const entity = this.entities.get(id)
if (entity === undefined) return false
const state = this.requireState()
const nextState = {
initialized: true,
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
}
await this.setState({
...nextState,
pendingMutation: { operation: 'delete', workspaceId: id },
})
this.entities.delete(id)
try {
await this.requireTable().delete(id)
} catch (error) {
this.entities.set(id, entity)
try {
await this.setState(state)
} catch (rollbackError) {
// The durable marker still says to finish deletion, so the cache must
// agree with that recoverable direction rather than republish a row
// absent from the persisted order.
this.entities.delete(id)
throw new AggregateError(
[error, rollbackError],
`workspace '${id}' record deletion and registry-order rollback both failed`,
)
}
throw error
}
try {
await this.setState(nextState)
} catch (error) {
// The deletion committed at the table write and was already published
// to Host streams. Keep the durable marker for startup recovery rather
// than reporting failure after the requested state became true.
this.ctx.logger.warn(
`workspace '${id}' was deleted but its pending marker could not be cleared: ${String(error)}`,
)
}
return true
}
/**
* Complete the one mutation explicitly named by durable state. Unexplained
* order/table divergence still reaches {@link validateStoredState} and
* fails loud; this path never infers provenance from shape alone.
*/
private async recoverPendingMutation(): Promise<void> {
const state = this.requireState()
const pending = state.pendingMutation
if (pending === undefined) return
if (state.workspaceIds.includes(pending.workspaceId)) {
throw new Error(
`workspace domain is inconsistent: pending ${pending.operation} workspace `
+ `'${pending.workspaceId}' is still present in registry order`,
)
}
await this.requireTable().delete(pending.workspaceId)
await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds })
}
private async bootstrap(headers: readonly SessionHeader[]): Promise<void> {
const table = this.requireTable()
const state = this.requireState()
@@ -454,7 +555,12 @@ export class WorkspaceRegistry extends Service {
}
private enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
const result = this.operationTail.then(operation)
const result = this.operationTail.then(async () => {
// A committed delete may leave only its marker cleanup pending. Retry
// recovery before another create/delete can overwrite that provenance.
await this.recoverPendingMutation()
return await operation()
})
this.operationTail = result.then(() => {}, () => {})
return result
}
@@ -20,8 +20,9 @@ export const inject = ['invariants']
* domain's durable table. Every `domain/changed` for the `workspaces` table
* must name a record the cache already holds an entity for (the registry
* caches before the durable put and mutates only through cached entities).
* A delete is valid only for create rollback, after the provisional cache
* entry has been removed; deleting a published entity proves a bypass.
* A delete is valid only after the registry has removed the entity from its
* cache, whether for create rollback or an explicit registration deletion;
* deleting while the cache still publishes the entity proves a bypass.
*/
const install: InvariantInstaller = Object.assign(
(ctx: Context, fail: (message: string) => never) => {
+11
View File
@@ -29,6 +29,16 @@ export const workspaceRecord = z.object({
/** One stored workspace record, inferred from {@link workspaceRecord}. */
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
/**
* Recoverable two-write mutation marker. The marker is persisted before the
* record/order pair can diverge, so startup can distinguish an interrupted
* registry operation from unexplained medium corruption.
*/
const workspacePendingMutation = z.discriminatedUnion('operation', [
z.object({ operation: z.literal('create'), workspaceId }),
z.object({ operation: z.literal('delete'), workspaceId }),
])
/**
* Durable registry state. `initialized` distinguishes a valid empty registry
* from one that still needs the header-only history bootstrap;
@@ -37,6 +47,7 @@ export type WorkspaceRecord = z.infer<typeof workspaceRecord>
export const workspaceDomainState = z.object({
initialized: z.boolean(),
workspaceIds: z.array(workspaceId),
pendingMutation: workspacePendingMutation.optional(),
})
/** Durable registry state inferred from {@link workspaceDomainState}. */
@@ -49,7 +49,7 @@ describe('workspace cache/table invariant', () => {
.toThrow(/cache still publishes/)
})
it('allows deletion only after a provisional create cache entry was removed for rollback', async () => {
it('allows deletion after the registry removed the cache entry for rollback or explicit deletion', async () => {
const ctx = await setup([])
expect(() => { ctx.emit('domain/changed', deleted()) }).not.toThrow()
})
@@ -89,7 +89,7 @@ async function storageContext(pool: MemoryMediaPool, backend: StorageBackend = n
/** Backend wrapper that injects one selected bootstrap write failure. */
function selectiveFailureBackend(
pool: MemoryMediaPool,
failure: { putAt?: number; deleteAt?: number; globalAt?: number },
failure: { putAt?: number; deleteAt?: number; globalAt?: number | readonly number[] },
): StorageBackend {
const inner = new MemoryStorageBackend(pool)
let puts = 0
@@ -113,7 +113,8 @@ function selectiveFailureBackend(
},
setGlobal: async (value) => {
globals += 1
if (globals === failure.globalAt) throw new Error('selected bootstrap marker failure')
const failAt = Array.isArray(failure.globalAt) ? failure.globalAt : [failure.globalAt]
if (failAt.includes(globals)) throw new Error('selected bootstrap marker failure')
await unit.setGlobal(value)
},
close: () => unit.close(),
@@ -394,19 +395,34 @@ describe('WorkspaceRegistry create and lookup', () => {
it('rolls back the provisional cache when the record write fails', async () => {
const dir = await makeDir('write-failure')
const result = await harness()
result.pool.failNextWrites = 1
await expect(result.registry.create(dir)).rejects.toThrow(/injected/)
const pool = new MemoryMediaPool()
const result = await harness({
pool,
backend: selectiveFailureBackend(pool, { putAt: 1 }),
})
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap put failure/)
expect(result.registry.list()).toEqual([])
expect(await result.registry.create(dir)).toBeDefined()
})
it('does not publish a Workspace when its pending marker cannot be written', async () => {
const dir = await makeDir('pending-marker-write-failure')
const pool = new MemoryMediaPool()
const result = await harness({
pool,
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
})
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap marker failure/)
expect(result.registry.list()).toEqual([])
expect(pool.media.get('workspace')!.tables.get('workspaces')?.size ?? 0).toBe(0)
})
it('rolls back a record when registry-order persistence fails', async () => {
const dir = await makeDir('order-write-failure')
const pool = new MemoryMediaPool()
const result = await harness({
pool,
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
backend: selectiveFailureBackend(pool, { globalAt: 3 }),
})
await expect(result.registry.create(dir)).rejects.toThrow(/marker failure/)
expect(result.registry.list()).toEqual([])
@@ -418,17 +434,129 @@ describe('WorkspaceRegistry create and lookup', () => {
const pool = new MemoryMediaPool()
const result = await harness({
pool,
backend: selectiveFailureBackend(pool, { globalAt: 2, deleteAt: 1 }),
backend: selectiveFailureBackend(pool, { globalAt: 3, deleteAt: 1 }),
})
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
})
it('reports a record write and pending-marker rollback failure together', async () => {
const dir = await makeDir('record-marker-rollback-failure')
const pool = new MemoryMediaPool()
const result = await harness({
pool,
backend: selectiveFailureBackend(pool, { putAt: 1, globalAt: 3 }),
})
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
expect(storedState(pool)).toMatchObject({
pendingMutation: { operation: 'create' },
})
})
it('reports an order write and pending-marker rollback failure together', async () => {
const dir = await makeDir('order-marker-rollback-failure')
const pool = new MemoryMediaPool()
const result = await harness({
pool,
backend: selectiveFailureBackend(pool, { globalAt: [3, 4] }),
})
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
expect(storedState(pool)).toMatchObject({
pendingMutation: { operation: 'create' },
})
})
it('deletes only the registration and leaves its directory and session headers untouched', async () => {
const dir = await makeDir('delete-registration')
const result = await harness({ sessions: [header('kept-session', dir)] })
const workspace = await result.registry.create(dir)
await workspace.attachSession(SessionId('kept-session'))
await expect(result.registry.delete(workspace.id)).resolves.toBe(true)
await expect(result.registry.delete(workspace.id)).resolves.toBe(false)
expect(result.registry.get(workspace.id)).toBeUndefined()
expect(result.registry.list()).toEqual([])
expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] })
expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false)
await expect(realpath(dir)).resolves.toBe(dir)
expect(result.list).toHaveBeenCalledTimes(1)
expect(result.load).not.toHaveBeenCalled()
expect(result.inspect).not.toHaveBeenCalled()
const reregistered = await result.registry.create(dir)
expect(reregistered.id).not.toBe(workspace.id)
expect(reregistered.path).toBe(dir)
expect(reregistered.sessionIds).toEqual([])
})
it('rolls registry order and cache back when record deletion fails', async () => {
const dir = await makeDir('delete-rollback')
const pool = new MemoryMediaPool()
const result = await harness({
pool,
backend: selectiveFailureBackend(pool, { deleteAt: 1 }),
})
const workspace = await result.registry.create(dir)
await expect(result.registry.delete(workspace.id)).rejects.toThrow(/selected rollback delete failure/)
expect(result.registry.get(workspace.id)).toBe(workspace)
expect(result.registry.list()).toEqual([workspace])
expect(storedState(pool).workspaceIds).toEqual([workspace.id])
expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir })
})
it('commits deletion and leaves a recoverable marker when marker cleanup fails', async () => {
const dir = await makeDir('delete-marker-cleanup')
const pool = new MemoryMediaPool()
const first = await harness({
pool,
backend: selectiveFailureBackend(pool, { globalAt: 5 }),
})
const workspace = await first.registry.create(dir)
await expect(first.registry.delete(workspace.id)).resolves.toBe(true)
expect(first.registry.list()).toEqual([])
expect(storedState(pool)).toEqual({
initialized: true,
workspaceIds: [],
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
})
const reregistered = await first.registry.create(dir)
expect(reregistered.id).not.toBe(workspace.id)
expect(storedState(pool)).toEqual({
initialized: true,
workspaceIds: [reregistered.id],
})
await first.fiber.dispose()
const restarted = await harness({ pool })
expect(restarted.registry.list().map(item => item.id)).toEqual([reregistered.id])
})
it('keeps the failed deletion unpublished when record and order rollback both fail', async () => {
const dir = await makeDir('delete-double-failure')
const pool = new MemoryMediaPool()
const result = await harness({
pool,
backend: selectiveFailureBackend(pool, { deleteAt: 1, globalAt: 5 }),
})
const workspace = await result.registry.create(dir)
await expect(result.registry.delete(workspace.id)).rejects.toBeInstanceOf(AggregateError)
expect(result.registry.get(workspace.id)).toBeUndefined()
expect(storedState(pool)).toMatchObject({
workspaceIds: [],
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
})
})
it('rejects table access before the registry has started', async () => {
const dir = await makeDir('unstarted')
const registry = new WorkspaceRegistry(new Context())
await expect(registry.create(dir)).rejects.toThrow(/not started/)
expect(() => registry.list()).toThrow(/not started/)
const internals = registry as unknown as { requireTable(): unknown }
expect(() => internals.requireTable()).toThrow(/not started/)
})
})
@@ -616,6 +744,49 @@ describe('header-validated membership projection', () => {
internals.entities.delete(workspace.id)
expect(() => result.registry.list()).toThrow(/references missing workspace/)
})
it('recovers only an explicitly marked interrupted create or delete', async () => {
const createDir = await makeDir('pending-create')
const deleteDir = await makeDir('pending-delete')
const createId = WorkspaceId('00000000-0000-4000-8000-000000000004')
const deleteId = WorkspaceId('00000000-0000-4000-8000-000000000005')
const interruptedCreate = storedPool(
[[createId, record(createDir, [])]],
{
initialized: true,
workspaceIds: [],
pendingMutation: { operation: 'create', workspaceId: createId },
},
)
const createRecovery = await harness({ pool: interruptedCreate })
expect(createRecovery.registry.list()).toEqual([])
expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false)
expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] })
const interruptedDelete = storedPool(
[[deleteId, record(deleteDir, [])]],
{
initialized: true,
workspaceIds: [],
pendingMutation: { operation: 'delete', workspaceId: deleteId },
},
)
const deleteRecovery = await harness({ pool: interruptedDelete })
expect(deleteRecovery.registry.list()).toEqual([])
expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false)
expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] })
const corruptPending = storedPool(
[[deleteId, record(deleteDir, [])]],
{
initialized: true,
workspaceIds: [deleteId],
pendingMutation: { operation: 'delete', workspaceId: deleteId },
},
)
await expect(harness({ pool: corruptPending })).rejects.toThrow(/still present in registry order/)
})
})
describe('workspace mutation and status', () => {