Merge remote-tracking branch 'origin/master' into fix/pty-handoff-grace
This commit is contained in:
112 files changed
+1912
-1561
No files matched your search
@@ -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
|
||||
2026-07-06-sandbox.md: 723ef170188dc11da24e049a1e2838fb240d0a17
|
||||
2026-07-06-sandbox.zh.md: a8c7743bb3d499fb58f507ea2c202b44efe2311d
|
||||
2026-07-06-sandbox.md: c6883873192f15ba2982436e156d8795396c0148
|
||||
2026-07-06-sandbox.zh.md: d84df9b06b15dd296801073d381603f34cfd2878
|
||||
@@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th
|
||||
|
||||
- **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence).
|
||||
- **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container).
|
||||
- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures.
|
||||
- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
|
||||
|
||||
- **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。
|
||||
- **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。
|
||||
- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。
|
||||
- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.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-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 path;SessionHeader 不增加 `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 两层拒绝同名 Workspace;cwd-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 Note(agent 决策记录):删除 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
-2
@@ -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.
|
||||
|
||||
|
||||
+3
-3
@@ -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 实现比较规范化后的路径标识,并采用所有宿主机均允许的文件名。ACP(Agent 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),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
|
||||
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+2
@@ -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` 支路,必须与托管支路保持同步。
|
||||
+6
@@ -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/testing/2026-07-26-execa-for-test-subprocess-plumbing.md
|
||||
2026-07-26-execa-for-test-subprocess-plumbing.md: 958abc4aee94adb3e6206cc299595ad92bde4044
|
||||
2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 7027a8bde51f81bfa7774743f84639cbd4b667d8
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Adopt execa for hand-rolled test subprocess plumbing
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Roughly ten e2e/smoke files re-derived the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`.
|
||||
|
||||
Two related test-infra hand-rolls compounded the case:
|
||||
|
||||
- `packages/support/llm-mock-server/src/cli.ts` hand-tokenized 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`).
|
||||
- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carried two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies dead.
|
||||
- The snapshot harness hand-rolled three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing.
|
||||
|
||||
## Decision
|
||||
|
||||
- `execa` is a root devDependency and a runtime dependency of `@deepseek-ai/dsh-loader-smoke` (the one `src/` consumer). The listed spawn-collect-timeout sites run through `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut, failed }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. `runLoaderSmoke` passes `input: ''` for its stdin-close contract, and sites whose assertions pin exact stream bytes pass `stripFinalNewline: false`.
|
||||
- The genuinely custom parts stay custom on top of an execa-owned subprocess: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. `smoke-real.e2e.ts` keeps raw `spawn` for its three long-lived interactive servers — ready-line watching across both streams plus a staged SIGTERM→await→SIGKILL teardown are the whole site, so execa would delete nothing there; its share of this note is the dead `.env` parser.
|
||||
- `llm-mock-server`'s CLI tokenizes via `parseArgs` (strict, no positionals); numeric coercion, bounds, and cross-option constraints stay manual, and the pinned error-message tests carry `parseArgs`'s own tokenizer texts.
|
||||
- Both `loadRootEnv` copies are deleted outright: the owning vitest configs (`vitest.web.config.ts` unconditionally, `vitest.snapshot.config.ts` in record mode) load the repo-root `.env` before those files run.
|
||||
- The four poll loops ride `vi.waitFor` with explicit `{ interval, timeout }` and descriptive errors thrown from the callback; `waitForPersistedTurnStart` captures its malformed-record validation error out of the retry loop so it fails the run immediately instead of being retried until the deadline.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical.
|
||||
- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn cross-platform timeout, termination, and result-normalization behavior that execa already carries.
|
||||
- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The hand-rolled collect/timeout blocks are gone, including the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke`: spawn and stream failures settle through execa's result fields, so the `src/` file carries no coverage exemptions and the per-file gate covers every remaining branch.
|
||||
- Captured output is bounded by execa's default 100 MB `maxBuffer` (overflow terminates the subprocess) where it was previously unbounded; the `loader-smoke` README's limitation entry reflects this.
|
||||
- Direct-child timeout termination and exit/signal result normalization are owned by execa across platforms instead of per-site hand-rolls; process-tree termination remains outside these helpers, as the `loader-smoke` README states. Each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform.
|
||||
- execa is a new root devDependency (previously absent from the lockfile); it is one of the most-depended-on packages on npm and actively maintained, and the exe/runtime closure is unaffected (tests only).
|
||||
- The mock-server CLI's tokenizer-level error texts are no longer this repo's to choose: unknown options, missing values, and stray positionals report `parseArgs`'s wording, pinned as such in `tests/cli.spec.ts`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 采用 execa 替换手写的测试子进程管道代码
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
大约十个 e2e/冒烟测试文件各自手工重写过同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。
|
||||
|
||||
另有两处相关的测试基础设施手写代码进一步强化了替换的理由:
|
||||
|
||||
- `packages/support/llm-mock-server/src/cli.ts` 曾手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。
|
||||
- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 曾携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝实为死代码。
|
||||
- 快照 harness 曾手写三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。
|
||||
|
||||
## 决定
|
||||
|
||||
- `execa` 是根 devDependency,同时是 `@deepseek-ai/dsh-loader-smoke`(唯一的 `src/` 消费者)的运行时依赖。上述 spawn、收集、超时的代码位置统一经由 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 运行:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut, failed }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。`runLoaderSmoke` 传 `input: ''` 以兑现其 stdin 关闭契约;断言固定精确流字节的位置传 `stripFinalNewline: false`。
|
||||
- 真正定制的部分继续保持定制,只是架在 execa 拥有的子进程之上:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。`smoke-real.e2e.ts` 的三个长驻交互式服务器保留原生 `spawn`——跨双流监听就绪行加上分级的 SIGTERM→等待→SIGKILL 拆除就是该处的全部内容,execa 在那里删不掉任何东西;它在本 note 中的份额是那份死的 `.env` 解析器。
|
||||
- `llm-mock-server` 的 CLI 经由 `parseArgs` 切分(strict、不允许位置参数);数值转换、边界检查与跨选项约束仍手工实现,被固定的错误消息测试改为携带 `parseArgs` 自己的切分器文本。
|
||||
- 两份 `loadRootEnv` 拷贝被整体删除:拥有它们的 vitest 配置(`vitest.web.config.ts` 无条件、`vitest.snapshot.config.ts` 在 record 模式下)在这些文件运行之前就加载了仓库根部的 `.env`。
|
||||
- 那四个轮询循环改乘 `vi.waitFor`,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误;`waitForPersistedTurnStart` 把「持久化记录格式非法」的校验错误捕获到重试循环之外,使其立即让运行失败,而不是被重试到截止时间。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。
|
||||
- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的跨平台超时、终止与结果规范化行为。
|
||||
- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。
|
||||
|
||||
## 后果
|
||||
|
||||
- 手写的收集/超时代码块全部移除,包括 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支:spawn 与流故障如今经由 execa 的结果字段结算,这个 `src/` 文件不再携带任何覆盖率豁免,逐文件门禁覆盖其余全部分支。
|
||||
- 捕获的输出如今受 execa 默认 100 MB `maxBuffer` 约束(溢出即终止子进程),此前是无界的;`loader-smoke` README 的局限条目反映了这一点。
|
||||
- 直接子进程的超时终止以及退出/信号结果规范化均由 execa 跨平台负责,不再逐处手写;如 `loader-smoke` README 所述,这些辅助函数依然不负责终止进程树。每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。
|
||||
- execa 是新增的根 devDependency(此前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,exe/运行时闭包不受影响(仅测试使用)。
|
||||
- mock-server CLI 切分器层面的错误文本不再由本仓库决定:未知选项、缺失取值与多余位置参数报告 `parseArgs` 的措辞,并在 `tests/cli.spec.ts` 中如此固定。
|
||||
+3
-3
@@ -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
|
||||
// delete:future 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.
|
||||
|
||||
|
||||
+8
-6
@@ -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
|
||||
// delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口
|
||||
delete(id: WorkspaceId): Promise<boolean> // 只删注册记录;目录与 session 日志保留
|
||||
}
|
||||
```
|
||||
|
||||
- **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——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 删除 RPC/GUI | 破坏性的 Session 删除产品流启动 | `session.delete` 端点、wire schema 与明确的确认 UI | Workspace RPC/GUI 已独立交付,不再存在级联耦合 |
|
||||
|
||||
## 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 接线时补。
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# 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-26-execa-for-test-subprocess-plumbing.md: 99a86258fe4d59db6a0e144dbcee94c095f70f8f
|
||||
2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 525e09f07ce3e5dc61f1cadab5c11ea0790cccee
|
||||
@@ -1,41 +0,0 @@
|
||||
# Agent Note: Adopt execa for hand-rolled test subprocess plumbing
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Roughly ten e2e/smoke files re-derive the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. Net deletable: ~100–150 lines of test infrastructure.
|
||||
|
||||
Two related test-infra hand-rolls compound the case:
|
||||
|
||||
- `packages/support/llm-mock-server/src/cli.ts` hand-tokenizes 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`).
|
||||
- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carry two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies arguably dead.
|
||||
- The snapshot harness hand-rolls three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing.
|
||||
|
||||
## Proposal
|
||||
|
||||
- Add `execa` as a root devDependency and rewrite the spawn-collect-timeout sites onto `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. Keep the genuinely custom parts custom: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography.
|
||||
- Swap `llm-mock-server`'s CLI tokenizer for `parseArgs` (numeric coercion, bounds, and cross-option constraints stay manual; pinned error-message texts update with the tests).
|
||||
- Delete both `loadRootEnv` copies in favor of `process.loadEnvFile` in a try/catch, or remove them outright if the vitest-config loading already covers them.
|
||||
- Replace the four poll loops with `vi.waitFor`/`expect.poll`, passing explicit `{ interval, timeout }` and throwing descriptive errors from the callback.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical.
|
||||
- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries.
|
||||
- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The listed sites spawn through execa (or the chosen equivalent); the hand-rolled collect/timeout blocks and the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke` are gone.
|
||||
- `llm-mock-server` CLI parses via `parseArgs`; its cli spec passes with updated message expectations.
|
||||
- No hand-rolled `.env` parser remains under `apps/web/tests`.
|
||||
- The affected e2e and snapshot suites pass on both POSIX and Windows CI lanes.
|
||||
|
||||
## Risks
|
||||
|
||||
- `loader-smoke` is a `src/` file under the per-file-100% coverage gate; the swap actually simplifies its coverage story (removes un-inducible branches) but the new call shape needs coverage.
|
||||
- Each rewritten e2e must be re-run on both platforms; subtle differences in kill escalation or stdin-close semantics (`input: ''` for loader-smoke's stdin-close contract) are the risk to verify per site.
|
||||
- execa is a new root devDependency (currently absent from the lockfile entirely); it is one of the most-depended-on packages on npm and actively maintained, so health is not a concern, but the exe/runtime closure is unaffected either way (tests only).
|
||||
@@ -1,41 +0,0 @@
|
||||
# Agent Note: 采用 execa 替换手写的测试子进程管道代码
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
大约十个 e2e/冒烟测试文件各自手工重写同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。净可删除量:约 100–150 行测试基础设施代码。
|
||||
|
||||
另有两处相关的测试基础设施手写代码进一步强化了替换的理由:
|
||||
|
||||
- `packages/support/llm-mock-server/src/cli.ts` 手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。
|
||||
- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝几乎可以视为死代码。
|
||||
- 快照 harness 手写了三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。
|
||||
|
||||
## 提案
|
||||
|
||||
- 将 `execa` 添加为根 devDependency,把上述 spawn、收集、超时的代码位置改写到 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 上:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。真正定制的部分继续保持定制:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。
|
||||
- 把 `llm-mock-server` 的 CLI 切分器换成 `parseArgs`(数值转换、边界检查与跨选项约束仍手工实现;被固定的错误消息文本随测试一并更新)。
|
||||
- 删除两份 `loadRootEnv` 拷贝,改用包在 try/catch 中的 `process.loadEnvFile`;如果 vitest 配置的加载已经覆盖了它们,则直接整体移除。
|
||||
- 用 `vi.waitFor`/`expect.poll` 替换那四个轮询循环,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。
|
||||
- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。
|
||||
- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 所列位置全部通过 execa(或最终选定的等价包)spawn 子进程;手写的收集/超时代码块,连同 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支,全部移除。
|
||||
- `llm-mock-server` 的 CLI 经由 `parseArgs` 解析;其 cli 测试文件在更新消息期望后通过。
|
||||
- `apps/web/tests` 下不再存在手写的 `.env` 解析器。
|
||||
- 受影响的 e2e 与快照测试套件在 POSIX 与 Windows 两条 CI 车道上均通过。
|
||||
|
||||
## 风险
|
||||
|
||||
- `loader-smoke` 是逐文件 100% 覆盖率门禁下的 `src/` 文件;这次替换实际上简化了它的覆盖率问题(移除了无法人为诱发的分支),但新的调用形态需要补齐覆盖。
|
||||
- 每个改写后的 e2e 都必须在两个平台上重新运行;终止信号升级或 stdin 关闭语义上的细微差异(loader-smoke 的 stdin 关闭契约对应 `input: ''`)是需要逐处核验的风险。
|
||||
- execa 是新增的根 devDependency(当前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,健康度不是顾虑;至于 exe/运行时闭包,无论选哪个包都不受影响(仅测试使用)。
|
||||
+2
-2
@@ -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
|
||||
2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 047449f4915c973e86cdb9f05f6dc51535133534
|
||||
2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 379d57e1e0006bf8f567d0b750ca0bb641ca6b49
|
||||
2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 236139f9198f178d44cdf0867cbad2377a127359
|
||||
2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 3932f73a2bf147ce5088b5c42e85982c70cdb945
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Agent Note: Evaluate landstrip before building a Windows sandbox launcher
|
||||
|
||||
Status: proposed
|
||||
Status: rejected — landstrip is not battle-tested (a days-old single-maintainer project, ~48 GitHub stars at rejection); a security-invariant dependency must have proven adoption, so the win32 rung keeps the in-house-launcher plan
|
||||
|
||||
English | [中文](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Agent Note: 在构建 Windows 沙箱启动器之前先评估 landstrip
|
||||
|
||||
Status: proposed
|
||||
Status: rejected — landstrip 未经实战检验(问世仅数天、单一维护者、驳回时 GitHub 星标约 48 个);安全不变式级的依赖必须有成熟的采用度,因此 win32 梯级维持自研启动器的原计划
|
||||
|
||||
[English](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) | 中文
|
||||
|
||||
+2
-2
@@ -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
|
||||
2026-07-26-dependency-swaps-rejected-by-nih-audit.md: f55bc2a9b7fb2a9599760734fca2a295665b95a2
|
||||
2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: f740e0717cffd1b2ff4f3f5db8c9775afdfe79f0
|
||||
2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 9cdb061bf21a7b9ce4747b9022c65d60e9644e0d
|
||||
2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 9648af149c0c517eba372baa12877240f9289aac
|
||||
+2
-2
@@ -46,7 +46,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu
|
||||
- **`shell-quote` for POSIX single-quoting**: two 1-line quoting helpers with exhaustive tests versus a maintenance-mode package with a CVE history and different escaping output — a safety boundary is the wrong place to save one line.
|
||||
- **`strip-ansi` for pty sanitization**: the pty sanitizer is a streaming state machine with split-sequence carry across chunks and OSC `133;D` prompt-marker extraction (the shell-readiness signal); stateless strippers replace ~20 inner lines while all state machinery stays. `stripVTControlCharacters` also demonstrably leaks unterminated-OSC payloads the session-title normalizer must strip (anti-spoofing).
|
||||
- **`pidtree`/`ps-tree` for the pty process inspector**: bare PID trees; the code needs start-time identity against PID reuse plus `/proc` stdin-wait detection no package does.
|
||||
- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa proposal](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).)
|
||||
- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).)
|
||||
- **`tree-kill` for acp-snapshot teardown and lsp process kill**: the lines are drain-ordering/error-propagation, not tree traversal; lsp/bash already use detached process groups + taskkill.
|
||||
- **node-pty everywhere for the TUI test driver**: [Windows-TUI note](../../implemented/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it is already the Windows leg.
|
||||
|
||||
@@ -67,7 +67,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu
|
||||
- **`syncpack`/`manypkg` for `check-workspace-constraints.ts`**: they cover ~20 lines of range alignment; the load-bearing 200+ lines (computed `files` lists, cordis peer=dev pairing, hierarchy shape) are repo policy no generic engine expresses.
|
||||
- **`remark-validate-links` for `verify-md-links.ts`**: the gate rides the repo's shared mdast toolchain; adopting remark-cli adds a second markdown stack to delete one small file.
|
||||
- **`prebuildify`/`node-gyp-build` for the landlock launcher packaging**: inapplicable — those load `.node` addons via dlopen; the launcher ships a standalone exec'd static binary, and per-platform `optionalDependencies` *is* the ecosystem convention for binaries.
|
||||
- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung is a different question — see the [landstrip evaluation proposal](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md).)
|
||||
- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung was weighed separately and also [rejected](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — landstrip is not battle-tested.)
|
||||
- **`hatch-nodejs-version` for Python release versioning**: roughly LOC-neutral (a custom metadata hook replaces the regex), inverts the recorded decision that the dev sentinel never determines a release version, and puts a single-maintainer build plugin in the release supply chain.
|
||||
- **YAML consolidation (`js-yaml` vs `yaml`)**: the repo carries both parsers, with the `!!js` tag defined four times on js-yaml (vendored include, app-boot, apps/cli, `scripts/verify-cordis-config.ts`) and twice on `yaml` (sdk-telemetry's `ScalarTag`, sdk-helper's comment-preserving Document editing). The direction is forced — js-yaml cannot replace `yaml` (sdk-helper needs the Document API) — but migrating the js-yaml sites cannot retire the library either (the vendored include pins it) and would put two parsers in charge of one dialect that must agree exactly, against the [personal-config note](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)'s deliberate load-only-copy parity. Deletable: ~20–25 lines of duplicate tag definitions and two `@types/js-yaml` entries. The consolidation moment is a future include sync, not now.
|
||||
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门
|
||||
- **以 `shell-quote` 承担 POSIX 单引号包裹**:两个各 1 行、测试详尽的引号辅助函数,对上一个处于维护模式、有 CVE 历史、转义输出还不一样的包——安全边界不是省一行代码的地方。
|
||||
- **以 `strip-ansi` 承担 pty 净化**:pty 净化器是一台流式状态机,带跨分片的断裂序列续接和 OSC `133;D` 提示符标记提取(shell 就绪信号);无状态的剥离器只能替掉约 20 行内层代码,全部状态机构件原样保留。`stripVTControlCharacters` 还被实证会泄漏未终止的 OSC 载荷,会话标题归一化器必须剥除它们(反欺骗)。
|
||||
- **以 `pidtree`/`ps-tree` 承担 pty 进程巡检器**:它们只给裸 PID 树;这段代码需要对抗 PID 复用的启动时间身份校验,加上 `/proc` stdin 等待检测,没有包做这些。
|
||||
- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa 提案](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。)
|
||||
- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。)
|
||||
- **以 `tree-kill` 承担 acp-snapshot 拆除与 lsp 进程终止**:那些代码行做的是排空顺序与错误传播,不是进程树遍历;lsp/bash 已经使用分离的进程组加 taskkill。
|
||||
- **在 TUI 测试驱动器上到处使用 node-pty**:[Windows TUI 决策](../../implemented/feature/2026-07-20-windows-tui-support.md)已明确否决在每个宿主上都用 node-pty;它已经是 Windows 那一条腿。
|
||||
|
||||
@@ -67,7 +67,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门
|
||||
- **以 `syncpack`/`manypkg` 替换 `check-workspace-constraints.ts`**:它们只覆盖约 20 行的版本范围对齐;承重的 200+ 行(计算生成的 `files` 列表、cordis peer=dev 配对、层级形状)是仓库政策,没有通用引擎能表达。
|
||||
- **以 `remark-validate-links` 替换 `verify-md-links.ts`**:该门禁搭载仓库共享的 mdast 工具链;采用 remark-cli 等于为删掉一个小文件而增加第二套 markdown 技术栈。
|
||||
- **以 `prebuildify`/`node-gyp-build` 承担 landlock 启动器打包**:不适用——那些工具通过 dlopen 加载 `.node` addon;这个启动器交付的是独立 exec 的静态二进制,而按平台划分的 `optionalDependencies` 恰恰*就是*二进制分发的生态惯例。
|
||||
- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级是另一个问题——见 [landstrip 评估提案](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。)
|
||||
- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级经单独权衡后同样被[驳回](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——landstrip 未经实战检验。)
|
||||
- **以 `hatch-nodejs-version` 承担 Python 发布版本号**:代码行数大致持平(一个自定义 metadata 钩子换掉那个正则),却反转了「dev 哨兵值绝不决定发布版本」这条记录在案的决策,还把一个单一维护者的构建插件放进发布供应链。
|
||||
- **YAML 归一(`js-yaml` 与 `yaml`)**:仓库同时携带两个解析器,`!!js` 标签在 js-yaml 上定义了四次(vendor 收录的 include、app-boot、apps/cli、`scripts/verify-cordis-config.ts`),在 `yaml` 上定义了两次(sdk-telemetry 的 `ScalarTag`、sdk-helper 的保留注释式 Document 编辑)。方向是被迫的——js-yaml 无法取代 `yaml`(sdk-helper 需要 Document API)——但迁移 js-yaml 各调用点也退休不了这个库(vendor 收录的 include 锁定了它),还会让两个解析器共管一种必须完全一致的方言,违背[个人配置决策](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)刻意的「仅加载副本」对等性。可删除的:约 20–25 行重复标签定义和两条 `@types/js-yaml` 条目。归一的时机是未来某次 include 同步,不是现在。
|
||||
|
||||
|
||||
+103
-8
@@ -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:
|
||||
|
||||
@@ -29,3 +29,4 @@ python/**/.pytest_cache/
|
||||
apps/web/dist/
|
||||
.artifacts/
|
||||
.playwright-mcp/
|
||||
.orig
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -22,25 +22,18 @@ import { describe, expect, it } from 'vitest'
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
|
||||
/** Run the built bin with PIPED stdio; resolve with output + exit code. */
|
||||
function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (c: string) => { stdout += c })
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (c: string) => { stderr += c })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 25_000)
|
||||
// Resolve on `close` (all stdio drained), not `exit`, so captured output is complete.
|
||||
child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
|
||||
child.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||
child.stdin.end()
|
||||
/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */
|
||||
async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [dshBin], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// the open llm seam post-boot with installLlmReplay on the settled root ctx
|
||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||
// assertConsumed for the teardown fixture-consumption check).
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -69,16 +69,6 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
|
||||
// contextWindow keeps that pressure path provably inert for small fixtures.
|
||||
const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
|
||||
|
||||
/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */
|
||||
function loadRootEnv(): void {
|
||||
const envPath = join(REPO_ROOT, '.env')
|
||||
if (!existsSync(envPath)) return
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
|
||||
if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
|
||||
}
|
||||
}
|
||||
|
||||
/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
|
||||
export interface WebScaffold {
|
||||
/** The active snapshot mode this scaffold booted under. */
|
||||
@@ -142,7 +132,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
requireDist()
|
||||
const mode = webSnapshotMode()
|
||||
if (mode === 'record') {
|
||||
loadRootEnv()
|
||||
// Both owning vitest configs (web unconditionally, snapshot in record
|
||||
// mode) load the repo-root .env before this file runs.
|
||||
if (process.env.DEEPSEEK_API_KEY === undefined || process.env.DEEPSEEK_API_KEY.length === 0) {
|
||||
throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
|
||||
// list in a real chromium, screenshot every screen into .artifacts/ for the
|
||||
// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
|
||||
// convention); the runner loads the repo-root .env explicitly because the CLI
|
||||
// only auto-loads .env from its cwd (a temp dir here, so sessions never land
|
||||
// in the repo's .sessions).
|
||||
// convention); vitest.web.config.ts loads the repo-root .env before this file
|
||||
// runs (the CLI only auto-loads .env from its cwd — a temp dir here, so
|
||||
// sessions never land in the repo's .sessions).
|
||||
//
|
||||
// Selector convention: CSS Modules hash as [hash]_[local], so class-substring
|
||||
// selectors are unreliable — anchor on data-* attributes (data-variant /
|
||||
@@ -26,17 +26,6 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Repo-root .env → process.env (never overrides an already-set variable). */
|
||||
function loadRootEnv(): void {
|
||||
const envPath = join(REPO_ROOT, '.env')
|
||||
if (!existsSync(envPath)) return
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
|
||||
if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
|
||||
}
|
||||
}
|
||||
loadRootEnv()
|
||||
|
||||
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
let out = ''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -6,6 +5,7 @@ import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
|
||||
@@ -69,7 +69,9 @@ describe('jsonrpc-agent keyless smoke', () => {
|
||||
await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
|
||||
const address = modelServer.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
|
||||
const child = spawn(process.execPath, [
|
||||
// The line-predicate protocol driving below is the genuinely custom part;
|
||||
// execa owns spawn, the deadline, and exit settlement around it.
|
||||
const child = execa(process.execPath, [
|
||||
'--import',
|
||||
'tsx',
|
||||
binScript,
|
||||
@@ -77,27 +79,26 @@ describe('jsonrpc-agent keyless smoke', () => {
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_CWD: root,
|
||||
DSH_SESSION_ROOT: join(root, '.sessions'),
|
||||
...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 35_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
const lines: string[] = []
|
||||
let stdoutBuffer = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdoutBuffer += chunk
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString('utf8')
|
||||
const parts = stdoutBuffer.split('\n')
|
||||
stdoutBuffer = parts.pop() ?? ''
|
||||
lines.push(...parts)
|
||||
})
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
|
||||
try {
|
||||
child.stdin.write(`${JSON.stringify({
|
||||
@@ -144,16 +145,8 @@ describe('jsonrpc-agent keyless smoke', () => {
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
|
||||
const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
|
||||
expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} })
|
||||
if (child.exitCode === null) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('exit', (code) => {
|
||||
if (code === 0) resolve()
|
||||
else reject(new Error(`runtime exited ${code}; stderr=${stderr}`))
|
||||
})
|
||||
})
|
||||
} else {
|
||||
expect(child.exitCode, stderr).toBe(0)
|
||||
}
|
||||
const exit = await child
|
||||
expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
|
||||
const sessionsRoot = join(root, '.sessions')
|
||||
const files = await readdir(sessionsRoot, { recursive: true })
|
||||
const log = files.find(file => file.endsWith('.jsonl.zstd'))
|
||||
@@ -162,14 +155,16 @@ describe('jsonrpc-agent keyless smoke', () => {
|
||||
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
|
||||
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
|
||||
} finally {
|
||||
if (child.exitCode === null) child.kill('SIGKILL')
|
||||
// No-op after exit; reject: false settles on every outcome, so cleanup never races teardown.
|
||||
child.kill('SIGKILL')
|
||||
await child
|
||||
await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 40_000)
|
||||
|
||||
it('rejects an invalid max-token success env value', async () => {
|
||||
const child = spawn(process.execPath, [
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, [
|
||||
'--import',
|
||||
'tsx',
|
||||
binScript,
|
||||
@@ -177,26 +172,17 @@ describe('jsonrpc-agent keyless smoke', () => {
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
|
||||
DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('exit', resolve)
|
||||
stdin: 'ignore',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
|
||||
expect(exitCode, stderr).toBe(1)
|
||||
expect(stdout).toBe('')
|
||||
expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc')
|
||||
}, 10_000)
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { execa } from 'execa'
|
||||
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const POSIX_PTY_DRIVER = String.raw`
|
||||
@@ -94,35 +94,32 @@ async function runPosixPtySmoke(
|
||||
options: TuiPtySmokeOptions,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn('python3', [
|
||||
'-c',
|
||||
POSIX_PTY_DRIVER,
|
||||
launch.command,
|
||||
JSON.stringify(launch.args),
|
||||
JSON.stringify(launch.env),
|
||||
cwd,
|
||||
JSON.stringify(options.actions ?? []),
|
||||
String(options.expectedExitCode ?? 0),
|
||||
String(timeoutMs / 1_000),
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, timeoutMs + 5_000)
|
||||
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) resolve(stdout)
|
||||
else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
})
|
||||
// The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline
|
||||
// only backstops a wedged python3 process itself.
|
||||
const result = await execa('python3', [
|
||||
'-c',
|
||||
POSIX_PTY_DRIVER,
|
||||
launch.command,
|
||||
JSON.stringify(launch.args),
|
||||
JSON.stringify(launch.env),
|
||||
cwd,
|
||||
JSON.stringify(options.actions ?? []),
|
||||
String(options.expectedExitCode ?? 0),
|
||||
String(timeoutMs / 1_000),
|
||||
], {
|
||||
stdin: 'ignore',
|
||||
timeout: timeoutMs + 5_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
stripFinalNewline: false,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
if (result.failed) {
|
||||
throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
async function runWindowsPtySmoke(
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
"@types/node": "^22.20.0",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"eslint": "^10.4.1",
|
||||
"execa": "^10.0.0",
|
||||
"eslint-plugin-sonarjs": "^4.1.0",
|
||||
"fast-check": "^4.8.0",
|
||||
"js-yaml": "^4.2.0",
|
||||
|
||||
@@ -750,6 +750,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)
|
||||
@@ -941,6 +955,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' },
|
||||
}))),
|
||||
|
||||
@@ -380,6 +380,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,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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
|
||||
@@ -1,590 +0,0 @@
|
||||
/**
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||
* id), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||
* resolution (pure — resolution has no side effects and is render-safe);
|
||||
* the event window and deferred teardown key off the STAGED session, which
|
||||
* follows `list.current` exactly. Staging is the open signal: the window
|
||||
* opens ⟺ the session is on stage (today the stage is `current`; the staged
|
||||
* state can widen to a multi-pane list later). A session leaving the list
|
||||
* tears its scope down immediately unless it is the staged one, whose scope
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
id: SessionId
|
||||
/** Latest durable log-backed title, absent until the host projects one. */
|
||||
title?: string
|
||||
/** Human-facing label: durable title, project basename, then session id. */
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). List surfaces hide blank
|
||||
* sessions; New Session reuses a blank one targeting the same workspace.
|
||||
* Filtering stays with the consumer — the store carries every row.
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Session list store shape. `current` rides the same snapshot (arbitrated:
|
||||
* the single useSessions standard hook reads list and selection together —
|
||||
* sidebar highlighting and SessionProvider share one fact source).
|
||||
*/
|
||||
export interface SessionListState {
|
||||
ids: SessionId[]
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
current: SessionId | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
}
|
||||
|
||||
/** Structured session-create failure. */
|
||||
export class SessionCreateError extends Error {
|
||||
override readonly name = 'SessionCreateError'
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: RpcError,
|
||||
readonly requestedSessionId: SessionId | undefined,
|
||||
) {
|
||||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: Session
|
||||
readonly ctx: Context
|
||||
}
|
||||
|
||||
// Scope primitives live in ../agents/scope.ts (the client mirror of host
|
||||
// dsh-scope, keyed by Agent identity); re-exported here so existing
|
||||
// consumers keep their import site.
|
||||
export { scopeOf } from '../agents/scope.ts'
|
||||
|
||||
/**
|
||||
* Workspace display title of a session cwd: the path's last non-empty
|
||||
* segment (both separators accepted; trailing separators ignored), or ''
|
||||
* for separator-only paths — callers own their fallback (session id, raw
|
||||
* cwd, default-directory copy). The repo-wide single basename derivation —
|
||||
* every surface naming a workspace (picker rows, toggle labels, list titles)
|
||||
* calls this instead of re-splitting paths.
|
||||
* @param cwd - workspace directory path.
|
||||
* @returns basename title, or '' when no non-empty segment exists.
|
||||
*/
|
||||
export function workspaceTitleOf(cwd: string): string {
|
||||
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Display title projection: durable title, project directory basename, then
|
||||
* the raw id.
|
||||
*/
|
||||
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
|
||||
if (title !== undefined) return title
|
||||
if (cwd !== undefined && cwd !== '') {
|
||||
const base = workspaceTitleOf(cwd)
|
||||
if (base !== '') return base
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
|
||||
provideInfo: SessionProvideInfo
|
||||
}
|
||||
|
||||
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
|
||||
export interface SessionProvideContribution {
|
||||
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
|
||||
hooks?: Record<string, HostObservable<unknown>>
|
||||
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Static declaration plus per-session resolver for one standard-kit
|
||||
* contribution. The declared names let the renderer construct the same hook
|
||||
* and prop surface while no session is current.
|
||||
*/
|
||||
export interface SessionProvideDescriptor {
|
||||
/** Hook base names (`input` becomes `useInput`). */
|
||||
hooks?: readonly string[]
|
||||
/** Plain standard-prop names. */
|
||||
props?: readonly string[]
|
||||
/** Resolve every declared member for one definite session. */
|
||||
resolve(binding: SessionBinding): SessionProvideContribution
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService {
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* SessionsService.open} / {@link SessionsService.clear}. Projection
|
||||
* validates it against the live list instead of destructively pruning, so a
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Registered per-session standard-props providers, in registration order. */
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
* `current` without moving the stage, so reconnect re-pulls and removals
|
||||
* keep the staged scope's frozen view alive until the stage moves on).
|
||||
*/
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
this.manager.subscribe(() => { this.projectList() })
|
||||
// Stage follower: every current write (open() and projection alike)
|
||||
// re-evaluates staging, so startup restore (persisted selection validated
|
||||
// by the projection) and reconnect resurfacing open their window with no
|
||||
// dedicated code path. Safe to run synchronously inside the store notify:
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
// The runtime's own contribution comes first: useSession rides the same
|
||||
// provide channel every plugin uses (no renderer special case).
|
||||
this.providers.push({
|
||||
hooks: ['session'],
|
||||
resolve: binding => ({ hooks: { session: binding.session } }),
|
||||
})
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider: every session-scope slot
|
||||
* component receives the contributed members as standard props (`hooks`
|
||||
* sources become `use<Name>` selector hooks on the render side; `props`
|
||||
* spread verbatim). Contributions materialize lazily with the session's
|
||||
* scope record and die with it. Registration order is resolution order;
|
||||
* duplicate member names fail loud at materialization.
|
||||
* @param descriptor - static member roster plus per-session resolver.
|
||||
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
|
||||
*/
|
||||
provide(descriptor: SessionProvideDescriptor): () => void {
|
||||
this.providers.push(descriptor)
|
||||
// Scopes may already exist (boot order: the list lands and resolves
|
||||
// scopes before later plugins register) — their bundles must include
|
||||
// every provider by first render, so re-materialize on roster change.
|
||||
this.rematerializeProvideBundles()
|
||||
return () => {
|
||||
const at = this.providers.indexOf(descriptor)
|
||||
if (at >= 0) this.providers.splice(at, 1)
|
||||
this.rematerializeProvideBundles()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
|
||||
private rematerializeProvideBundles(): void {
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
|
||||
const hooks: Record<string, undefined> = {}
|
||||
const props: Record<string, undefined> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = undefined
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = undefined
|
||||
}
|
||||
}
|
||||
return { sessionId: undefined, hooks, props }
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
|
||||
const hooks: Record<string, HostObservable<unknown>> = {}
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
const contribution = descriptor.resolve(binding)
|
||||
const contributedHooks = contribution.hooks ?? {}
|
||||
const contributedProps = contribution.props ?? {}
|
||||
for (const name of Object.keys(contributedHooks)) {
|
||||
if (!(descriptor.hooks ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared hook "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(contributedProps)) {
|
||||
if (!(descriptor.props ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared prop "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
const source = contributedHooks[name]
|
||||
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = source
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = contributedProps[name]
|
||||
}
|
||||
}
|
||||
return { sessionId: binding.sessionId, hooks, props }
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere.
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
this.manager.select(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
* Wipes the persisted selection too — a reload stays on empty until the
|
||||
* user opens or starts a session. The staged scope keeps its frozen view
|
||||
* per the masked-gap contract until the next open() moves the stage.
|
||||
*/
|
||||
clear(): void {
|
||||
this.manager.clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started baseline pull.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mux stream envelope into the Session object layer.
|
||||
* @param envelope - validated mux stream envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
|
||||
this.manager.handleMuxEnvelope(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Host stream envelope into the Session object layer.
|
||||
* @param envelope - validated Host stream envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
|
||||
this.manager.handleHostEnvelope(envelope)
|
||||
}
|
||||
|
||||
/** Rebuild the Session baseline and every opened window after connection. */
|
||||
handleConnected(): void {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host. Resolution guarantee: by the time the
|
||||
* promise resolves, the created session is in the list store and
|
||||
* {@link SessionsService.binding} resolves it — callers (New Session
|
||||
* draft hand-off) may address the scope synchronously, without waiting a
|
||||
* notifier flush. The synchronous projection below makes this structural
|
||||
* rather than an accident of microtask ordering.
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @throws {SessionCreateError} with the requested id.
|
||||
*/
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Agent scope tag off a context. Service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeTagOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the business Session behind an Agent-scoped context — the one
|
||||
* hop every scoped consumer (event listeners, per-session controllers)
|
||||
* takes from ctx-space into object-space (the client mirror of host
|
||||
* `agent.session`). Same service-method seam as
|
||||
* {@link SessionsService.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): Session | undefined {
|
||||
const id = scopeTagOf(ctx)
|
||||
if (id === undefined) return undefined
|
||||
return this.scopes.get(id)?.binding.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable session binding (scope-addressed assembly feed). Pure
|
||||
* resolution — no staging, no window side effects.
|
||||
* @param id - session id.
|
||||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
binding(id: SessionId): SessionBinding | undefined {
|
||||
return this.resolve(id)?.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer standard-props bundle (SessionProvider's feed
|
||||
* through the renderer host; ctx never enters the render layer). Pure
|
||||
* resolution — render-safe: SessionProvider calls this during render, so no
|
||||
* staging, no window side effects (StrictMode double-invokes and concurrent
|
||||
* discarded passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns the provide info, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
return this.resolve(id as SessionId)?.provideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current-session-optional standard kit. Unknown or absent ids
|
||||
* return the static no-session projection rather than removing hook props.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns a definite or no-session provide bundle.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the stage to the list's current session: sweep teardowns deferred
|
||||
* behind the previous occupant and pull the new occupant's history window.
|
||||
* Staging IS the open signal — the window opens ⟺ the session is on stage
|
||||
* — and open() is idempotent (an in-flight or completed open no-ops; a
|
||||
* failed one retries the next time current is touched).
|
||||
*/
|
||||
private followCurrent(): void {
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const current = snapshot.current
|
||||
// A masked gap (current blanked while the selection's session is
|
||||
// transiently absent) holds the stage: tearing down on the gap would
|
||||
// destroy exactly the frozen scope the mask exists to preserve.
|
||||
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
|
||||
this.watched = current
|
||||
this.sweepDeferred()
|
||||
const record = this.resolve(current)
|
||||
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
|
||||
* validates and the projection masks absent selections), so resolve
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.binding.session.open()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb feed: walk parentId links inside the list store.
|
||||
* @param id - session id.
|
||||
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
|
||||
*/
|
||||
ancestry(id: SessionId): SessionSummary[] {
|
||||
const { byId } = this.list.getSnapshot()
|
||||
const chain: SessionSummary[] = []
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
const summary: SessionSummary | undefined = byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||
* prune share one predicate (decision 12): listed on the host — a scope is
|
||||
* born when its session enters the client's view (list mirror row from the
|
||||
* baseline pull, a create() echo, or the session-added frame) and dies with
|
||||
* the prune when the row leaves.
|
||||
*/
|
||||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||||
const existing = this.scopes.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
if (!this.eligible(id)) return undefined
|
||||
const { fiber, ctx } = createScope(this.rootCtx, id)
|
||||
const session = this.manager.get(id)
|
||||
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
|
||||
// mint and bind are one step so a live scope record implies a bound actx.
|
||||
session.bindScope(ctx)
|
||||
const binding: SessionBinding = { sessionId: id, session, ctx }
|
||||
const record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding,
|
||||
// Sources are bare observables; React binds selector hooks at its own seam.
|
||||
provideInfo: this.materializeProvideInfo(binding),
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
|
||||
private eligible(id: SessionId): boolean {
|
||||
return this.list.getSnapshot().byId[id] !== undefined
|
||||
}
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const { items, current, phase } = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
ids.push(entry.sessionId)
|
||||
byId[entry.sessionId] = {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
||||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||||
if (current === undefined) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
||||
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
||||
void byId
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (this.eligible(id)) continue
|
||||
if (id === this.watched) {
|
||||
this.deferredRemovals.add(id)
|
||||
continue
|
||||
}
|
||||
this.scopes.delete(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One teardown for the whole per-session axis (decision 12): the scope
|
||||
* fiber (cascading every actx-registered effect: input shell, slash
|
||||
* controller, popup, plugin stores, listeners), the session-keyed slot
|
||||
* stores, and the Session instance itself — the host session log is the
|
||||
* durable truth, a reopen lazily rebuilds and backfills via open().
|
||||
*/
|
||||
private dropScope(id: SessionId, record: ScopeRecord): void {
|
||||
void record.fiber.dispose()
|
||||
// Release the Session's dispatch point with the scope it belongs to (a
|
||||
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
||||
record.binding.session.unbindScope()
|
||||
// Optional lookup: slots and sessions are sibling services with no
|
||||
// declared dependency; a slots-less boot (object-layer tests) skips.
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
this.manager.drop(id)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
/* v8 ignore next -- defensive: only the staged id ever defers, and every
|
||||
* stage move sweeps first, so the set cannot contain the id the stage just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Eligible again? (A re-added id cancels the deferred teardown.)
|
||||
if (this.eligible(id)) {
|
||||
this.deferredRemovals.delete(id)
|
||||
continue
|
||||
}
|
||||
const record = this.scopes.get(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
||||
* together, so a deferred id always still owns its record; kept so a
|
||||
* future teardown path cannot double-dispose. */
|
||||
if (record !== undefined) {
|
||||
this.scopes.delete(id)
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills
|
||||
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
|
||||
* and WorkspacePicker fills the conversation hero's picker hole
|
||||
* (`conversation.hero.workspace` — both hero forms). Both read real Host
|
||||
* Workspaces through the global useWorkspaces hook. Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from './stores.ts'
|
||||
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
|
||||
import { WorkspacePicker } from './WorkspacePicker.tsx'
|
||||
|
||||
export type {
|
||||
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slots are declared by
|
||||
* the ui-sidebar / ui-conversation applies, whose activation order relative
|
||||
* to this one is NOT constrained: dshClient.inject edges are informational
|
||||
* (loading/prefetch metadata, never apply sequencing) and neither owner
|
||||
* provides a waitable service. apply therefore registers via
|
||||
* declaration-aware deferral instead of assuming order.
|
||||
*/
|
||||
export const inject = ['slots', 'sessions', 'workspaces']
|
||||
|
||||
/**
|
||||
* Register the browser and picker once their slot declarations are on the
|
||||
* ledger. Inject factories return plain callbacks; data reads use the
|
||||
* framework's global hooks.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const browserInjected = (): WorkspaceBrowserInjected => ({
|
||||
// With a workspace: materialize (reuse-or-create the blank session) and
|
||||
// navigate. Without one: clear the selection — the layout's empty seat
|
||||
// shows the New Session pure view state and the user picks there.
|
||||
startSession: (workspaceId) => {
|
||||
if (workspaceId === undefined) {
|
||||
ctx.sessions.clear()
|
||||
return
|
||||
}
|
||||
void ctx.workspaces.connectWorkspace(workspaceId).then(
|
||||
(sessionId) => { ctx.sessions.open(sessionId) },
|
||||
(reason: unknown) => { console.warn('new session failed:', reason) },
|
||||
)
|
||||
},
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
})
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
})
|
||||
// Declaration-aware registration: each owner's declaring apply may activate
|
||||
// after this one (entry activation order is unconstrained), and a register
|
||||
// into an undeclared slot throws. Register once the declaration is on the
|
||||
// ledger; the subscription also re-registers after an HMR collapse
|
||||
// re-declares the slot (the cascade disposed our entry with it).
|
||||
ctx.effect(() => {
|
||||
const registrations = [
|
||||
{
|
||||
name: 'sidebar.workspaces' as const,
|
||||
component: WorkspaceBrowser,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
|
||||
WorkspaceBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'conversation.hero.workspace' as const,
|
||||
component: WorkspacePicker,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'conversation.hero.workspace', inject: pickerInjected },
|
||||
WorkspacePicker,
|
||||
),
|
||||
},
|
||||
]
|
||||
const disposers = new Map<string, () => void>()
|
||||
const tryRegister = (entry: (typeof registrations)[number]): void => {
|
||||
if (ctx.slots.spec(entry.name) === undefined) return
|
||||
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
|
||||
disposers.set(entry.name, entry.register())
|
||||
}
|
||||
const unsubscribers = registrations.map(entry =>
|
||||
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
|
||||
for (const entry of registrations) tryRegister(entry)
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
}
|
||||
}, 'ui-workspace: browser + picker registrations')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Derives the workspace browser tree from Host Workspace order and membership.
|
||||
* Unassigned Sessions trail under Ungrouped; blank Sessions remain visible.
|
||||
*/
|
||||
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Group key for Sessions outside every Workspace. */
|
||||
export const UNGROUPED_KEY = ''
|
||||
|
||||
/** Display label for the ungrouped bucket row. */
|
||||
export const UNGROUPED_LABEL = 'Ungrouped'
|
||||
|
||||
/** One session node of a group's visible tree (34px row; children render indented one step). */
|
||||
export interface SessionNode {
|
||||
id: SessionId
|
||||
title: string
|
||||
/** Visible children, already expansion/search-filtered (empty when folded). */
|
||||
children: readonly SessionNode[]
|
||||
/** The session HAS children in the data (the twist renders even while folded). */
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
running: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One workspace group section: header row facts + the visible session tree. */
|
||||
export interface GroupNode {
|
||||
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
|
||||
key: string
|
||||
/** Backing Workspace id; absent only for the ungrouped bucket. */
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
/** Total visible sessions in the group. */
|
||||
sessionCount: number
|
||||
expanded: boolean
|
||||
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
|
||||
containsCurrent: boolean
|
||||
/** Visible roots (empty while the group is folded). */
|
||||
sessions: readonly SessionNode[]
|
||||
}
|
||||
|
||||
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
|
||||
export interface TreeView {
|
||||
expandedProjects: readonly string[]
|
||||
expandedSessions: readonly string[]
|
||||
query: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
summaries: Map<SessionId, SessionSummary>
|
||||
roots: SessionId[]
|
||||
children: Map<SessionId, SessionId[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory display label: basename of the path (both separators accepted).
|
||||
* Ungrouped-bucket fallback for surfaces without a workspace title.
|
||||
* @param cwd - directory path, or undefined for the ungrouped bucket.
|
||||
* @returns basename, the raw cwd when it has no basename, or the ungrouped label.
|
||||
*/
|
||||
export function projectLabel(cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return UNGROUPED_LABEL
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */
|
||||
function byRecency(a: SessionSummary, b: SessionSummary): number {
|
||||
if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
|
||||
/** Build one group's parent/child tree from an ordered member list. */
|
||||
function buildGroup(
|
||||
key: string,
|
||||
workspaceId: WorkspaceId | undefined,
|
||||
cwd: string | undefined,
|
||||
label: string,
|
||||
members: readonly SessionSummary[],
|
||||
order: 'account' | 'recency',
|
||||
): Group {
|
||||
const summaries = new Map(members.map(m => [m.id, m]))
|
||||
const children = new Map<SessionId, SessionId[]>()
|
||||
const roots: SessionSummary[] = []
|
||||
for (const m of members) {
|
||||
// A session is a tree child only when its parent lives in the same
|
||||
// group; cross-group or unknown parents degrade to group roots.
|
||||
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
|
||||
const kids = children.get(m.parentId)
|
||||
if (kids === undefined) children.set(m.parentId, [m.id])
|
||||
else kids.push(m.id)
|
||||
} else {
|
||||
roots.push(m)
|
||||
}
|
||||
}
|
||||
// Workspace order is the member iteration order (workspace.sessionIds), so
|
||||
// attached groups keep insertion order; Ungrouped sorts by recency.
|
||||
if (order === 'recency') {
|
||||
roots.sort(byRecency)
|
||||
for (const kids of children.values()) {
|
||||
kids.sort((a, b) => {
|
||||
const sa = summaries.get(a)
|
||||
const sb = summaries.get(b)
|
||||
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
|
||||
if (sa === undefined || sb === undefined) return 0
|
||||
return byRecency(sa, sb)
|
||||
})
|
||||
}
|
||||
}
|
||||
const rootIds = roots.map(r => r.id)
|
||||
// parentId cycles (host bug) leave members unreachable from any root;
|
||||
// surface them as extra roots — the flatten walk's visited set stops
|
||||
// loops. Each node sits in at most one kids list and roots have no
|
||||
// in-group parent, so the scan pushes every reachable node exactly once.
|
||||
const reachable = new Set<SessionId>(rootIds)
|
||||
const stack = [...rootIds]
|
||||
while (stack.length > 0) {
|
||||
const top = stack.pop()
|
||||
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
|
||||
if (top === undefined) break
|
||||
for (const kid of children.get(top) ?? []) {
|
||||
reachable.add(kid)
|
||||
stack.push(kid)
|
||||
}
|
||||
}
|
||||
for (const m of members) {
|
||||
if (!reachable.has(m.id)) rootIds.push(m.id)
|
||||
}
|
||||
return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
|
||||
}
|
||||
|
||||
/**
|
||||
* Group Sessions by Host Workspace: one group per entity in stable Host
|
||||
* order, with members resolved from sessionIds in their stored order. Sessions
|
||||
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
|
||||
*/
|
||||
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
|
||||
const groups: Group[] = []
|
||||
const accounted = new Set<SessionId>()
|
||||
for (const workspace of workspaces) {
|
||||
const members: SessionSummary[] = []
|
||||
for (const id of workspace.sessionIds) {
|
||||
const summary = list.byId[id]
|
||||
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
|
||||
accounted.add(id)
|
||||
members.push(summary)
|
||||
}
|
||||
groups.push(buildGroup(
|
||||
workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
|
||||
))
|
||||
}
|
||||
const stray = list.ids
|
||||
.map(id => list.byId[id])
|
||||
.filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id))
|
||||
if (stray.length > 0) {
|
||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.displayTitle,
|
||||
children,
|
||||
hasChildren,
|
||||
expanded,
|
||||
running: s.running,
|
||||
updatedAt: s.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return null
|
||||
const kids = g.children.get(id) ?? []
|
||||
const expanded = expandedSessions.has(id)
|
||||
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
|
||||
return sessionNode(s, children, kids.length > 0, expanded)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/** Matched sessions plus their ancestor chains (forced visible under search). */
|
||||
function searchVisible(g: Group, q: string): Set<SessionId> {
|
||||
const visible = new Set<SessionId>()
|
||||
for (const m of g.summaries.values()) {
|
||||
if (!m.displayTitle.toLowerCase().includes(q)) continue
|
||||
let cur: SessionSummary | undefined = m
|
||||
while (cur !== undefined && !visible.has(cur.id)) {
|
||||
visible.add(cur.id)
|
||||
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id) || !visible.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return null
|
||||
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
|
||||
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
return sessionNode(s, children, kids.length > 0, kids.length > 0)
|
||||
}
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the nested workspace browser group structure.
|
||||
*
|
||||
* Normal mode: every group shows; sessions populate under expanded groups,
|
||||
* descending only into expanded sessions. Search mode (non-blank query,
|
||||
* case-insensitive display-title substring): expansion state is ignored —
|
||||
* matched sessions and their ancestor chains are forced visible, groups
|
||||
* without a display-title or label hit are dropped, and a label-only hit
|
||||
* keeps the bare group header. Blank sessions are excluded everywhere.
|
||||
* @param list - sessions list snapshot (`current` feeds containsCurrent).
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param view - local expansion arrays and search query.
|
||||
* @returns group sections in render order.
|
||||
*/
|
||||
export function deriveGroups(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const expandedSessions = new Set(view.expandedSessions)
|
||||
const currentGroup = list.current === undefined
|
||||
? undefined
|
||||
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
const groups: GroupNode[] = []
|
||||
for (const g of groupByWorkspace(list, workspaces)) {
|
||||
if (q === '') {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: expanded ? buildVisible(g, expandedSessions) : [],
|
||||
})
|
||||
} else {
|
||||
const visible = searchVisible(g, q)
|
||||
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded: visible.size > 0,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
sessions: buildSearch(g, visible),
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the flat session list ("In one list" mode): every session — fork
|
||||
* children included — as a top-level row, strictly newest-first. No grouping,
|
||||
* no parent/child adjacency; rows reuse SessionNode with children always
|
||||
* empty so the renderer stays branch-free. Search mode filters by
|
||||
* case-insensitive display-title substring.
|
||||
* @param list - sessions list snapshot.
|
||||
* @param view - the search query (expansion state does not apply).
|
||||
* @returns flat rows in render order.
|
||||
*/
|
||||
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const rows: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined) continue
|
||||
if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
return rows.map(s => sessionNode(s, [], false, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
|
||||
* @param updatedAt - epoch ms of the session's last activity.
|
||||
* @param now - current epoch ms (injected for pure rendering).
|
||||
* @returns the row's trailing time label.
|
||||
*/
|
||||
export function formatRelativeTime(updatedAt: number, now: number): string {
|
||||
const MIN = 60_000
|
||||
const HOUR = 3_600_000
|
||||
const DAY = 86_400_000
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < MIN) return 'now'
|
||||
if (diff < HOUR) return `${Math.floor(diff / MIN)}min`
|
||||
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`
|
||||
if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d`
|
||||
if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo`
|
||||
return `${Math.floor(diff / (365 * DAY))}y`
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -36,12 +36,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
console.log(JSON.stringify(result))
|
||||
process.exit(0)
|
||||
`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: pkgDir,
|
||||
stdin: 'ignore',
|
||||
timeout: 55_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
|
||||
@@ -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 */',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { promisify } from 'node:util'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -209,25 +210,19 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */
|
||||
function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, [acpBin, '--config', configArg], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
let stderr = ''
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (c: string) => { stderr += c })
|
||||
const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000)
|
||||
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) })
|
||||
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||
proc.stdin.end()
|
||||
/** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */
|
||||
async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [acpBin, '--config', configArg], {
|
||||
cwd,
|
||||
env: {
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`)
|
||||
return { code: result.exitCode ?? -1, stderr: result.stderr }
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -6,6 +5,7 @@ import { dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -114,36 +114,34 @@ interface BinResult {
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
|
||||
return new Promise((resolveResult, reject) => {
|
||||
const child = spawn(process.execPath, [cliBin, ...args], {
|
||||
cwd,
|
||||
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
|
||||
const subprocess = execa(process.execPath, [cliBin, ...args], {
|
||||
cwd,
|
||||
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdin: 'ignore',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
stripFinalNewline: false,
|
||||
})
|
||||
// Genuinely custom mid-stream logic: the signal cases deliver `interrupt`
|
||||
// once the first streamed chunk proves the turn is in flight.
|
||||
if (interrupt !== undefined) {
|
||||
let streamed = ''
|
||||
let interrupted = false
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) {
|
||||
subprocess.stdout.on('data', (chunk: Buffer) => {
|
||||
streamed += chunk.toString('utf8')
|
||||
if (!interrupted && streamed.includes('assistant/chunk')) {
|
||||
interrupted = true
|
||||
child.kill(interrupt)
|
||||
subprocess.kill(interrupt)
|
||||
}
|
||||
})
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 25_000)
|
||||
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
resolveResult({ code: code ?? -1, signal, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
const result = await subprocess
|
||||
if (result.timedOut) {
|
||||
throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
|
||||
let consumer: string | undefined
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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: {} } },
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
@@ -57,12 +57,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
console.log(JSON.stringify(result))
|
||||
await ctx.fiber.dispose()
|
||||
`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: pkgDir,
|
||||
stdin: 'ignore',
|
||||
timeout: 55_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
|
||||
+26
-29
@@ -1,10 +1,10 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import SessionStore, {
|
||||
SessionId, TOOL_OUTCOME_UNKNOWN,
|
||||
type SessionEvent,
|
||||
@@ -19,22 +19,21 @@ const roots: string[] = []
|
||||
const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
|
||||
|
||||
async function waitForMarker(path: string, expected: string): Promise<string> {
|
||||
const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS
|
||||
for (;;) {
|
||||
try {
|
||||
const content = await readFile(path, 'utf8')
|
||||
if (content === expected) return content
|
||||
if (!expected.startsWith(content)) {
|
||||
throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// vi.waitFor retries every callback throw, so terminal states RESOLVE out
|
||||
// of the retry loop (complete marker, or content that can no longer become
|
||||
// the expected marker) and only the still-in-progress states throw-to-retry.
|
||||
const content = await vi.waitFor(async () => {
|
||||
const current = await readFile(path, 'utf8').catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error })
|
||||
})
|
||||
if (current === expected || !expected.startsWith(current)) return current
|
||||
throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`)
|
||||
}, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS })
|
||||
if (content !== expected) {
|
||||
throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
|
||||
@@ -44,26 +43,24 @@ async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; marker
|
||||
// Keep the open-before-write window deterministic: readiness is marker content, not path existence.
|
||||
await writeFile(marker, '')
|
||||
const expectedMarker = mode === 'request' ? 'request-dispatched' : 'tool-side-effect'
|
||||
const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
|
||||
// The SIGKILL-at-failpoint choreography stays custom: the child must die
|
||||
// mid-write, so no timeout or graceful termination may reach it first.
|
||||
const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
|
||||
stdin: 'ignore',
|
||||
stdout: 'ignore',
|
||||
reject: false,
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
try {
|
||||
const markerText = await waitForMarker(marker, expectedMarker)
|
||||
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||
child.once('close', (code, signal) => { resolve({ code, signal }) })
|
||||
})
|
||||
child.kill('SIGKILL')
|
||||
const exit = await closed
|
||||
expect(exit).toEqual({ code: null, signal: 'SIGKILL' })
|
||||
const exit = await child
|
||||
expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' })
|
||||
return { root, markerText }
|
||||
} catch (error: unknown) {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
|
||||
throw new Error(`crash child failed: ${stderr}`, { cause: error })
|
||||
child.kill('SIGKILL')
|
||||
throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
README.md: d35872e5bb06be88dc5999bfa1800083b2fbbf3c
|
||||
README.zh.md: a706b6db5408538c578cb2a1cfc3aa99804a930d
|
||||
README.md: d22e6e2d95a1ed930a7f4876daf4b06e2f761f7a
|
||||
README.zh.md: 514b7ebfe02cb34ed559633a0fd82cf6194fa4b3
|
||||
@@ -57,7 +57,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon
|
||||
|
||||
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
|
||||
|
||||
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
|
||||
Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ defineAcpSnapshotSuite({
|
||||
|
||||
示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。
|
||||
|
||||
约束:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入(启动器、harness 和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
|
||||
约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { existsSync, realpathSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, delimiter } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { vi } from 'vitest'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -457,17 +457,25 @@ async function waitForPersistedTurnStart(
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
minimumTurn?: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
let invalidRecord: { error: unknown } | undefined
|
||||
await vi.waitFor(async () => {
|
||||
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
|
||||
const openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
|
||||
if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return
|
||||
if (Date.now() >= deadline) {
|
||||
let openTurn: number | undefined
|
||||
try {
|
||||
openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
|
||||
} catch (error) {
|
||||
// A malformed persisted record is a scenario bug, not a not-yet state:
|
||||
// vi.waitFor retries every callback throw, so capture the validation
|
||||
// failure, resolve the wait, and rethrow immediately below.
|
||||
invalidRecord = { error }
|
||||
return
|
||||
}
|
||||
if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) {
|
||||
const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}`
|
||||
throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
if (invalidRecord !== undefined) throw invalidRecord.error
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -481,15 +489,12 @@ async function waitForPersistedTurnEnd(
|
||||
sessionId: string,
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
await vi.waitFor(async () => {
|
||||
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
|
||||
if (log !== undefined && latestTurnIsClosed(log.content)) return
|
||||
if (Date.now() >= deadline) {
|
||||
if (log === undefined || !latestTurnIsClosed(log.content)) {
|
||||
throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
}
|
||||
|
||||
/** Wait for a cwd-relative marker proving an external action reached readiness. */
|
||||
@@ -499,13 +504,11 @@ async function waitForWorkspaceFile(
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const target = join(cwd, path)
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!existsSync(target)) {
|
||||
if (Date.now() >= deadline) {
|
||||
await vi.waitFor(() => {
|
||||
if (!existsSync(target)) {
|
||||
throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
}
|
||||
|
||||
/** Return whether the last complete raw-JSONL turn boundary closes its turn. */
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-llm-mock-server/cli
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts'
|
||||
import type {
|
||||
ConcreteMockLlmBehavior,
|
||||
@@ -63,14 +64,6 @@ Other:
|
||||
--help
|
||||
`
|
||||
|
||||
function optionValue(argv: readonly string[], index: number, option: string): string {
|
||||
const value = argv[index + 1]
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
throw new Error(`dsh-llm-mock-server: ${option} requires a value`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function numberValue(option: string, value: string): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`)
|
||||
@@ -122,66 +115,64 @@ function parseRandomWeights(raw: string): MockLlmRandomWeights {
|
||||
return weights
|
||||
}
|
||||
|
||||
/** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */
|
||||
const CLI_OPTIONS = {
|
||||
'sequence': { type: 'string' },
|
||||
'host': { type: 'string' },
|
||||
'port': { type: 'string' },
|
||||
'api-key': { type: 'string' },
|
||||
'listen-delay-ms': { type: 'string' },
|
||||
'repeat-last': { type: 'boolean' },
|
||||
'seed': { type: 'string' },
|
||||
'random-weights': { type: 'string' },
|
||||
'success-text': { type: 'string' },
|
||||
'partial-text': { type: 'string' },
|
||||
'reasoning-text': { type: 'string' },
|
||||
'chunk-size': { type: 'string' },
|
||||
'chunk-delay-ms': { type: 'string' },
|
||||
'disconnect-delay-ms': { type: 'string' },
|
||||
'retry-after-ms': { type: 'string' },
|
||||
'request-id': { type: 'string' },
|
||||
'tool-name': { type: 'string' },
|
||||
'tool-arguments': { type: 'string' },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parse standalone server arguments without starting a process or listener.
|
||||
* Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric
|
||||
* coercion, bounds, and cross-option constraints remain manual below it.
|
||||
* @param argv - arguments after the executable name.
|
||||
* @returns help or validated run configuration.
|
||||
*/
|
||||
export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult {
|
||||
if (argv.includes('--help')) return { kind: 'help' }
|
||||
|
||||
let sequenceRaw: string | undefined
|
||||
let host: string | undefined
|
||||
let port = 8_000
|
||||
let apiKey: string | undefined
|
||||
let listenDelayMs: number | undefined
|
||||
let repeatLast = false
|
||||
let randomSeed: number | undefined
|
||||
let randomWeights: MockLlmRandomWeights | undefined
|
||||
let successText: string | undefined
|
||||
let partialText: string | undefined
|
||||
let reasoningText: string | undefined
|
||||
let chunkSize: number | undefined
|
||||
let chunkDelayMs: number | undefined
|
||||
let disconnectDelayMs: number | undefined
|
||||
let retryAfterMs: number | undefined
|
||||
let requestId: string | undefined
|
||||
let toolName: string | undefined
|
||||
let toolArguments: string | undefined
|
||||
const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false })
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const option = argv[index] as string
|
||||
if (option === '--repeat-last') {
|
||||
repeatLast = true
|
||||
continue
|
||||
}
|
||||
const value = optionValue(argv, index, option)
|
||||
index += 1
|
||||
switch (option) {
|
||||
case '--sequence': sequenceRaw = value; break
|
||||
case '--host': host = value; break
|
||||
case '--port': port = numberValue(option, value); break
|
||||
case '--api-key': apiKey = value; break
|
||||
case '--listen-delay-ms':
|
||||
listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
|
||||
break
|
||||
case '--seed': randomSeed = numberValue(option, value); break
|
||||
case '--random-weights': randomWeights = parseRandomWeights(value); break
|
||||
case '--success-text': successText = value; break
|
||||
case '--partial-text': partialText = value; break
|
||||
case '--reasoning-text': reasoningText = value; break
|
||||
case '--chunk-size': chunkSize = numberValue(option, value); break
|
||||
case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break
|
||||
case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break
|
||||
case '--retry-after-ms': retryAfterMs = numberValue(option, value); break
|
||||
case '--request-id': requestId = value; break
|
||||
case '--tool-name': toolName = value; break
|
||||
case '--tool-arguments': toolArguments = value; break
|
||||
default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`)
|
||||
}
|
||||
}
|
||||
const host = values.host
|
||||
const port = values.port === undefined ? 8_000 : numberValue('--port', values.port)
|
||||
const apiKey = values['api-key']
|
||||
const listenDelayMs = values['listen-delay-ms'] === undefined
|
||||
? undefined
|
||||
: boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
|
||||
const repeatLast = values['repeat-last'] ?? false
|
||||
const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed)
|
||||
const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights'])
|
||||
const successText = values['success-text']
|
||||
const partialText = values['partial-text']
|
||||
const reasoningText = values['reasoning-text']
|
||||
const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size'])
|
||||
const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms'])
|
||||
const disconnectDelayMs = values['disconnect-delay-ms'] === undefined
|
||||
? undefined
|
||||
: numberValue('--disconnect-delay-ms', values['disconnect-delay-ms'])
|
||||
const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms'])
|
||||
const requestId = values['request-id']
|
||||
const toolName = values['tool-name']
|
||||
const toolArguments = values['tool-arguments']
|
||||
|
||||
if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
|
||||
if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
|
||||
const sequenceRaw = values.sequence
|
||||
const parsedSequence = parseSequence(sequenceRaw)
|
||||
if (parsedSequence.startsUnavailable && port === 0) {
|
||||
throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port')
|
||||
|
||||
@@ -101,8 +101,11 @@ describe('mock LLM server CLI parser', () => {
|
||||
|
||||
it.each([
|
||||
[[], /--sequence is required/],
|
||||
[['--wat'], /requires a value/],
|
||||
[['--wat', 'x'], /unknown option/],
|
||||
// Tokenizer-level failures carry node:util parseArgs's own messages.
|
||||
[['--wat'], /Unknown option '--wat'/],
|
||||
[['--wat', 'x'], /Unknown option '--wat'/],
|
||||
[['--port'], /Option '--port <value>' argument missing/],
|
||||
[['--sequence', 'success', 'stray'], /Unexpected argument 'stray'/],
|
||||
[['--port', 'NaN', '--sequence', 'success'], /finite number/],
|
||||
[['--sequence', 'success,'], /non-empty/],
|
||||
[['--sequence', 'success,connection_refused'], /only as the first/],
|
||||
@@ -110,7 +113,8 @@ describe('mock LLM server CLI parser', () => {
|
||||
[['--sequence', 'unknown'], /unknown behavior/],
|
||||
[['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/],
|
||||
[['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/],
|
||||
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/],
|
||||
// `=` syntax: a space-separated leading-dash value is a tokenizer error, not a bounds probe.
|
||||
[['--sequence', 'connection_refused,success', '--listen-delay-ms=-1'], /integer between 0 and 2147483647/],
|
||||
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/],
|
||||
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/],
|
||||
[['--sequence', 'success', '--seed', '1'], /require random/],
|
||||
|
||||
@@ -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
|
||||
README.md: 8e53550608037a3c9a272db825933b7224ab24db
|
||||
README.zh.md: 5310429ab59cf3cd04ac024746f5ed557e003637
|
||||
README.md: 73610ce50ebac4c6fc7bb9135f7b41b347c60685
|
||||
README.zh.md: 17f8481220136e8edf9fccd23fabfca5ccf41dfc
|
||||
@@ -19,5 +19,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`.
|
||||
- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it.
|
||||
- **Captured stdout and stderr are bounded only by execa's default 100 MB `maxBuffer`** — a runaway child is terminated at that ceiling rather than at a smoke-chosen budget.
|
||||
- **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup.
|
||||
@@ -19,5 +19,5 @@
|
||||
## 已知限制与待完成工作
|
||||
|
||||
- **构建 mode 需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。
|
||||
- **捕获的 stdout 和 stderr 无界**:失控子进程可以消耗内存,直到 deadline 将其终止。
|
||||
- **捕获的 stdout 和 stderr 仅受 execa 默认 100 MB `maxBuffer` 约束**:失控子进程会在该上限处被终止,而不是在冒烟测试自选的预算处。
|
||||
- **超时只终止直接子进程**:故障 fixture 生成的进程树可以比冒烟测试存活更久,需要外部清理。
|
||||
@@ -27,6 +27,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"execa": "^10.0.0",
|
||||
"tsx": "^4.22.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
* @module @deepseek-ai/dsh-loader-smoke
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { execa } from 'execa'
|
||||
|
||||
const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
|
||||
|
||||
@@ -171,53 +171,27 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
|
||||
tsconfigPath: options.tsconfigPath,
|
||||
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
|
||||
})
|
||||
const result = await new Promise<LoaderSmokeResult>((resolve, reject) => {
|
||||
const child = spawn(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...launch.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let deferredFailure: Error | undefined
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)
|
||||
child.kill('SIGKILL')
|
||||
}, processTimeoutMs)
|
||||
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (deferredFailure !== undefined) {
|
||||
reject(deferredFailure)
|
||||
} else if (code === 0) {
|
||||
resolve({ stdout, stderr })
|
||||
} else {
|
||||
reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}
|
||||
})
|
||||
|
||||
// process.execPath and a just-created pipe make these OS-error paths
|
||||
// impractical to induce without replacing the boundary under test.
|
||||
/* v8 ignore start */
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error(`${options.label} failed to start: ${error.message}`))
|
||||
})
|
||||
child.stdin.once('error', (error) => {
|
||||
deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`)
|
||||
child.kill('SIGKILL')
|
||||
})
|
||||
/* v8 ignore stop */
|
||||
|
||||
child.stdin.end()
|
||||
// `input: ''` writes nothing and closes stdin — the fixture-visible
|
||||
// stdin-close contract. `reject: false` folds spawn errors, the SIGKILL
|
||||
// deadline, and nonzero exits into independent result fields, so the
|
||||
// diagnostics below embed both streams on every failure.
|
||||
const result = await execa(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: launch.env,
|
||||
input: '',
|
||||
timeout: processTimeoutMs,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
stripFinalNewline: false,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
if (result.failed) {
|
||||
throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
await options.inspect?.(cwd)
|
||||
return result
|
||||
return { stdout: result.stdout, stderr: result.stderr }
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user