The translators' clause-by-clause verification pass caught the English pages' final wording refinements (tui built-in dialogs enumeration and display()'s line-feed exception, invariants whitespace-padded/companion phrasing, client-modules fiber-lifecycle parenthetical, storage strict sequence, workspace first-successful-start) landing after the initial zh versions; this aligns the eight pages and re-records their pairs.
122 lines
8.4 KiB
Markdown
122 lines
8.4 KiB
Markdown
# 工作区
|
||
|
||
[English](workspace.md) | 中文
|
||
|
||
工作区(workspace)是用户工作目录的持久记录:一个建立在规范路径之上的稳定 id、一个显示标题,以及归属于它的会话的有序账本。该子系统是单个包(package)([dsh-workspace](../../packages/workspace/workspace),`ctx.workspace`)——一项宿主侧可选能力,不属于 agent loop(智能体循环)主干,并且对模型不可见(没有工具、没有提示词文本、没有会话事件)。它通过[存储领域数据形式](storage.md)存储自己的记录,并对照 [`SessionHeader.cwd`](persistence.md#sessionheader--metadata-beside-the-log) 校验会话成员资格,因此 `storageDomain` 与 `sessionPersistence` 是必需的启动依赖:持久化这一依赖不可用时,插件保持 pending,而不是把这种不可用误当作空历史。设计记录:[领域 KV 存储 Agent Note(agent 决策记录)](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md);引导与 GUI 顺序:[Workspace UI 产品流程 Agent Note](../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md)。
|
||
|
||
源码:[`packages/workspace/workspace/src/types.ts`](../../packages/workspace/workspace/src/types.ts)
|
||
|
||
## 标识
|
||
|
||
```ts type-equiv
|
||
/**
|
||
* Identifies one workspace record. A generated uuid, never the path: path
|
||
* normalization rewrites paths, and a reference anchor must stay stable.
|
||
*/
|
||
type WorkspaceId = Branded<'WorkspaceId'>
|
||
```
|
||
|
||
`WorkspaceId` 是[品牌化 id](core.md#branded-ids)。路径标识与之分离:`realpathNormalize`(`fs.realpath`;尾部斜杠、`..` 与符号链接全部解析)是唯一的一套唯一性规范——工作区路径以规范化形式存储,唯一性即规范路径的字符串相等(指向已被拥有目录的符号链接会与之冲突),attach 时的会话 cwd 检查也走同一套规范。
|
||
|
||
## 工作区实体
|
||
|
||
消费方只看到 `Workspace` 接口;实现保持包内私有。
|
||
|
||
```ts type-equiv
|
||
/**
|
||
* One workspace: a stable id over an existing directory, a display title, and
|
||
* an ordered candidate account of sessions. Membership requires both an id in
|
||
* that account and a session header whose canonical cwd equals the workspace
|
||
* path. Consumers only see this interface; the implementation stays private.
|
||
*/
|
||
interface Workspace {
|
||
/** Stable record id (generated uuid). */
|
||
readonly id: WorkspaceId
|
||
|
||
/**
|
||
* Canonical directory path: the `fs.realpath` of the path given at create
|
||
* time (trailing slashes, `..`, and symlinks all resolved). Never rewritten
|
||
* afterwards, even when the directory disappears (see {@link status}).
|
||
*/
|
||
readonly path: string
|
||
|
||
/** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
|
||
readonly title: string
|
||
|
||
/** ISO-8601 creation instant, stamped at create and never rewritten. */
|
||
readonly createdAt: string
|
||
|
||
/** ISO-8601 instant of the last durable mutation (create counts as one). */
|
||
readonly updatedAt: string
|
||
|
||
/**
|
||
* Header-validated sessions in manually owned order: a new session is
|
||
* prepended at attach, explicit reordering goes through
|
||
* `insertSessionBefore`, and activity never reorders. The durable candidate
|
||
* account is filtered synchronously: missing headers, invalid cwd values,
|
||
* and canonical cwd mismatches are never returned. A subsequent workspace
|
||
* mutation prunes those filtered candidates durably.
|
||
*/
|
||
readonly sessionIds: readonly SessionId[]
|
||
|
||
/**
|
||
* Replace the display title durably.
|
||
* @param title - New title; any string, duplicates across workspaces allowed.
|
||
* @returns resolution after durability.
|
||
*/
|
||
setTitle(title: string): Promise<void>
|
||
|
||
/**
|
||
* Prepend a session to this workspace's candidate account. An already
|
||
* accounted id resolves without writing. A new id's live or persisted
|
||
* header cwd must resolve to an existing directory equal to {@link path};
|
||
* unknown ids, missing or invalid cwd values, and mismatches reject without
|
||
* writing.
|
||
* @param sessionId - The session to record.
|
||
* @returns resolution after durability.
|
||
*/
|
||
attachSession(sessionId: SessionId): Promise<void>
|
||
|
||
/**
|
||
* Move an accounted session within the manual order, DOM-insertBefore-like:
|
||
* with an anchor the session lands before it, without one it appends to the
|
||
* end. Only the moved id changes position. A session or anchor absent from
|
||
* the account rejects without writing; a move to the current position
|
||
* resolves without writing (decided on the domain write chain).
|
||
* @param sessionId - The accounted session to move.
|
||
* @param beforeSessionId - Accounted anchor to insert before; omitted appends.
|
||
* @returns resolution after durability.
|
||
*/
|
||
insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>
|
||
|
||
/**
|
||
* Remove a session from this workspace's account. Idempotent: an id not on
|
||
* the account resolves without writing (decided on the domain write chain,
|
||
* like attach). Never touches the session's own stored log.
|
||
* @param sessionId - The session to remove.
|
||
* @returns resolution after durability.
|
||
*/
|
||
detachSession(sessionId: SessionId): Promise<void>
|
||
|
||
/**
|
||
* Live directory check, uncached: whether {@link path} currently exists and
|
||
* is a directory. A missing directory never mutates the record — the
|
||
* directory may only be temporarily moved.
|
||
* @returns `'ok'` when the directory exists, `'missing-dir'` otherwise.
|
||
*/
|
||
status(): Promise<'ok' | 'missing-dir'>
|
||
}
|
||
```
|
||
|
||
所有权的真源是记录中有序的 `sessionIds`,绝不从会话 cwd 派生——但成员资格要求两者同时成立:账本上有其 id,且 header 的规范 cwd 等于工作区路径,因此一个会话在结构上至多属于一个工作区。失败的写入会拒绝(`insertSessionBefore` 的账本错误以 `WorkspaceMoveInvalidError` 拒绝,存储失败以普通错误拒绝);每次被接受的变更都盖上 `updatedAt` 时间戳,并持久修剪不再通过成员资格检查的候选项。
|
||
|
||
## 注册表:`ctx.workspace`
|
||
|
||
`WorkspaceRegistry`([签名](../cordis-catalog/services.md#ctxworkspace--workspaceregistry))拥有注册与解析。`create(path, title?)` 规范化路径,拒绝不存在的路径(原样传出原始 `ENOENT`)或非目录;当规范路径已被拥有时原样返回既有实体;否则创建一条标题为 `title ?? basename(path)` 的记录并前插到持久的注册表顺序中——新记录不得与既有显示标题重复(`WorkspaceNameConflictError`)。`get(id)` 与有序的 `list()` 是同步缓存读取;`resolveByPath(path)` 应用同一套 realpath 规范但不创建。`delete(id)` 只移除注册记录、顺序条目和会话账本——目录、用户文件、实时会话和已持久化日志一概不动,因此这些会话变为 Ungrouped([决策](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md));未知 id 返回 `false`。create 与 delete 会在其两次写入(记录 + 顺序)可能分叉之前先持久写入一个待定变更标记;启动时恰好补完被标记的那次变更,而没有标记的顺序/表不一致则作为损坏大声失败。
|
||
|
||
会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。
|
||
|
||
## 消费方
|
||
|
||
[dsh-host-apiproxy](../../packages/host/apiproxy) 是产品消费方:它经 `ctx.workspace` 向 GUI 客户端提供工作区的 CRUD,并执行上文「先建会话再 attach」的流程。[dsh-workspace-context](../../packages/context/workspace-context) 尽管名字如此,却**不是**消费方:它在 agent 自己的 cwd 下发现 AGENTS.md 风格的指令文件,从不触碰 `ctx.workspace`——两者共用的这个词指的是用户的工作目录,而非本注册表的实体。
|