2026-07-27 23:29:15 +08:00
# 工作区
[English ](workspace.md ) | 中文
2026-08-18 19:00:37 +08:00
工作区( workspace) 是用户工作目录的持久记录: 一个建立在规范路径之上的稳定 id、一个显示标题, 以及归属于它的会话的有序账本。该子系统是单个包( package) ( [dsh-workspace ](../../packages/workspace/workspace ), `ctx.workspaceRegistry` )——一项宿主侧可选能力,不属于 agent loop( 智能体循环) 主干, 并且对模型不可见( 没有工具、没有提示词文本、没有会话事件) 。它通过[存储领域数据形式 ](storage.zh.md )存储自己的记录,并对照 [`SessionHeader.cwd` ](persistence.zh.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.zh.md );引导与 GUI 顺序:[Workspace UI 产品流程 Agent Note ](../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md )。
2026-07-27 23:29:15 +08:00
源码:[`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'>
```
2026-08-18 19:00:37 +08:00
`WorkspaceId` 是[品牌化 id ](core.zh.md#branded-ids )。路径标识与之分离:`realpathNormalize` ( `fs.realpath` ;尾部斜杠、`..` 与符号链接全部解析) 是唯一的一套唯一性规范——工作区路径以规范化形式存储, 唯一性即规范路径的字符串相等( 指向已被拥有目录的符号链接会与之冲突) , attach 时的会话 cwd 检查也走同一套规范。
2026-07-27 23:29:15 +08:00
## 工作区实体
消费方只看到 `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
2026-07-28 14:11:18 +08:00
* accounted id resolves without writing, aside from the durable
* filtered-candidate prune every accepted mutation performs. A new id's
* live or persisted
2026-07-27 23:29:15 +08:00
* 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
2026-07-28 14:11:18 +08:00
* resolves without writing, aside from the durable filtered-candidate
* prune every accepted mutation performs; decided on the domain write
* chain.
2026-07-27 23:29:15 +08:00
* @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
2026-07-28 14:11:18 +08:00
* the account resolves without writing, aside from the durable
* filtered-candidate prune every accepted mutation performs; decided on
* the domain write chain like attach. Never touches the session's own stored log.
2026-07-27 23:29:15 +08:00
* @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` 时间戳,并持久修剪不再通过成员资格检查的候选项。
2026-08-13 00:36:22 +08:00
## 注册表:`ctx.workspaceRegistry`
2026-07-27 23:29:15 +08:00
2026-08-18 19:00:37 +08:00
`WorkspaceRegistry` ( [签名 ](#ctxworkspaceregistry--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.zh.md ));未知 id 返回 `false` 。create 与 delete 会在其两次写入(记录 + 顺序)可能分叉之前先持久写入一个待定变更标记;启动时恰好解决被标记的那次变更——通过删除被标记的表行:这会补完被中断的 delete, 并回滚被中断的 create( 注册可以重建, 因此回滚是安全方向) ——而没有标记的顺序/表不一致则作为损坏大声失败。
2026-07-27 23:29:15 +08:00
2026-08-18 19:00:37 +08:00
会话的 cwd 在创建时由创建者赋予, 而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd( 回退到显式或默认 cwd) , 先创建会话使 cwd 落入其不可变的 [`SessionHeader` ](persistence.zh.md#sessionheader--metadata-beside-the-log ),再调用 `attachSession` ,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header( `id` 、`cwd` 、`createdAt` ——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped, 此后创建的会话只能通过 `attachSession` 加入工作区。
2026-07-27 23:29:15 +08:00
## 消费方
2026-08-13 00:36:22 +08:00
[dsh-host-apiproxy ](../../packages/host/apiproxy ) 是产品消费方:它经 `ctx.workspaceRegistry` 向 GUI 客户端提供工作区的 CRUD, 并执行上文「先建会话再 attach」的流程。[dsh-agent-instructions ](../../packages/context/agent-instructions ) 尽管名字如此,却**不是**消费方:它在 agent 自己的 cwd 下发现 AGENTS.md 风格的指令文件,从不触碰 `ctx.workspaceRegistry` ——两者共用的这个词指的是用户的工作目录,而非本注册表的实体。
2026-07-30 21:40:58 +08:00
<!-- BEGIN GENERATED cordis - surface (gen - cordis - catalog.ts) — do not edit between markers -->
< a id = "cordis-surface" > < / a >
2026-07-24 19:54:25 +08:00
## Cordis API
2026-07-30 21:40:58 +08:00
2026-08-18 21:02:50 +08:00
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog` ) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer ](../cordis-primer.zh.md#dispatch-modes ), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md ](../cordis-api/inherited.md ).
2026-07-30 21:40:58 +08:00
< a id = "ctxdirectorypicker--directorypicker-abstract-seam" > < / a >
### `ctx.directoryPicker` — `DirectoryPicker` (abstract seam)
Abstract directory-picking service. Subclass, implement `capability()` , and load the subclass as a plugin — it registers as `ctx.directoryPicker` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). The capability object must be stable for the service lifetime: consumers may capture it across calls.
```ts cordis-catalog
/**
* The backend's interaction capability.
* @returns the discriminated capability consumers switch on.
*/
abstract capability(): DirectoryPickerCapability
```
2026-08-04 11:38:42 +08:00
Source: [`packages/host/directory-picker/src/index.ts` ](../../packages/host/directory-picker/src/index.ts )
2026-07-30 21:40:58 +08:00
2026-08-23 06:14:32 +08:00
< a id = "ctxworkspacecontroller--workspacecontroller" > < / a >
### `ctx.workspaceController` — `WorkspaceController`
Host service backing the generated `ctx.remote.workspace` namespace.
```ts cordis-catalog
/**
* Create or idempotently resolve one Workspace over an existing directory.
* @param request - directory path to register.
* @returns the Workspace and whether this call created it.
*/
@Remote ('create') create(request: WorkspaceCreateRequest): Promise< WorkspaceCreateValue >
/**
* Rename one Workspace to a unique non-blank title.
* @param request - Workspace identity and proposed title.
* @returns the updated Workspace projection.
*/
@Remote ('rename') rename(request: WorkspaceRenameRequest): Promise< WorkspaceValue >
/**
* Remove one Workspace registration while retaining files and Sessions.
* @param request - Workspace identity to remove.
* @returns deletion confirmation.
*/
@Remote ('delete') delete(request: WorkspaceDeleteRequest): Promise< WorkspaceDeleteValue >
/**
* Move one Workspace within the registry display order.
* @param request - moved Workspace and optional anchor.
* @returns the complete resulting Workspace order.
*/
@Remote ('insertBefore') insertBefore(request: WorkspaceInsertBeforeRequest): Promise< WorkspaceOrderValue >
/**
* Move one accounted Session within a Workspace.
* @param request - Workspace, Session, and optional anchor identities.
* @returns the updated Workspace projection.
*/
@Remote ('insertSessionBefore') insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise< WorkspaceValue >
/**
* Hide one known Session from Workspace grouping surfaces.
* @param request - Session identity to archive.
* @returns the complete resulting archive set.
*/
@Remote ('archiveSession') archiveSession(request: WorkspaceArchiveSessionRequest): Promise< WorkspaceArchiveValue >
/**
* Stream a complete Workspace baseline followed by ordered increments.
* @param signal - generation cancellation.
* @returns baseline followed by ordered Workspace increments.
*/
@Remote ({ mode: 'stream' }) follow(signal: AbortSignal): AsyncIterable< WorkspaceFollowFrame >
```
Source: [`packages/api/workspace-controller/src/index.ts` ](../../packages/api/workspace-controller/src/index.ts )
2026-08-13 00:36:22 +08:00
< a id = "ctxworkspaceregistry--workspaceregistry" > < / a >
2026-07-30 21:40:58 +08:00
2026-08-13 00:36:22 +08:00
### `ctx.workspaceRegistry` — `WorkspaceRegistry`
2026-07-30 21:40:58 +08:00
Durable workspace registry. Startup waits for `sessionPersistence` , builds one canonical-cwd header index, and completes the one-time history bootstrap before the service becomes active. The persistence dependency is mandatory so an unavailable peer can never be mistaken for an empty history and commit the initialized marker.
```ts cordis-catalog
/**
* Create or reuse a workspace for an existing directory. The path is
* canonicalized through `fs.realpath` ; a nonexistent path rejects with the
* original error and a non-directory rejects. Repeated calls for the same
* canonical path return the existing entity without changing its title.
* A newly created workspace is prepended to the durable registry order.
* Different canonical paths may share a display title.
* @param path - Existing directory to own, in any path spelling.
* @param title - Display title used only when a new record is created.
* @returns the existing or newly durable workspace.
*/
async create(path: string, title?: string): Promise< Workspace >
/**
* Look up a workspace by id.
* @param id - Workspace id.
* @returns the workspace, or `undefined` when unknown.
*/
get(id: WorkspaceId): Workspace | undefined
/**
* Synchronous workspace projection in durable registry order. Every
* entity's `sessionIds` getter is already filtered by the startup/live
* canonical-cwd header index; this method performs no persistence reads.
* @returns a fresh ordered array of workspace entities.
*/
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 >
2026-08-11 15:24:56 +08:00
/**
* Move one workspace within the durable display order, DOM-insertBefore-like.
* With an anchor it lands before that workspace; without one it appends.
* @param id - Workspace to move.
* @param beforeId - Workspace anchor; omitted appends.
* @returns the complete committed workspace order.
*/
insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise< readonly WorkspaceId [ ] >
2026-07-30 21:40:58 +08:00
/**
* Archive one session durably. The session must exist (live or in session
* persistence); its workspace accounting — or lack of one — is irrelevant.
* An already archived id resolves without writing.
* @param sessionId - The session to archive.
* @returns resolution after durability.
*/
archiveSession(sessionId: SessionId): Promise< void >
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath` ; an existing unowned
* directory returns `undefined` .
* @param path - Existing directory path in any spelling.
* @returns the workspace owning the canonical path, when one exists.
*/
async resolveByPath(path: string): Promise< Workspace | undefined >
```
2026-08-18 19:00:37 +08:00
Types: [SessionId ](core.zh.md )
2026-07-30 21:40:58 +08:00
2026-08-04 11:38:42 +08:00
Source: [`packages/workspace/workspace/src/index.ts` ](../../packages/workspace/workspace/src/index.ts )
2026-07-30 21:40:58 +08:00
<!-- END GENERATED cordis - surface -->