# Volume / Workspace 架构

这份文档说明 `xpert` 当前新的 Volume / Workspace 双层路径模型。

核心约定只有两条：

- `Volume` 永远表示 server 进程可见的文件系统路径。
- `Workspace` 永远表示 sandbox 可见的文件系统路径。

两者不是同一个概念，但它们之间存在稳定映射关系。

## 为什么要拆开

同一份底层数据，在不同运行位置看到的路径并不一样：

- server 直接跑在开发机上时，server 看到的是本机路径。
- server 跑在 Docker API 容器里时，server 看到的是 API 容器内路径。
- sandbox 跑在本机 shell 或另一个容器里时，sandbox 看到的是它自己的工作目录。

如果继续把这些路径都叫 `workspacePath`，调用方很容易把：

- server 路径
- Docker bind source
- sandbox 工作目录

混在一起传递。

新架构的目标就是把这三层拆开，并通过显式映射重新连起来。

## 核心对象

### `VolumeScope`

`packages/server-ai/src/shared/volume/volume.ts` 中的 `VolumeScope` 只表达逻辑卷定位，不表达 sandbox/provider 细节。

主要字段：

- `tenantId`
- `catalog`
- `projectId`
- `userId`
- `xpertId`
- `knowledgeId`
- `workspaceId`
- `environmentId`
- `rootId`
- `isolateByUser`

### `VolumeClient`

`VolumeClient` 负责 server 侧路径解析。

主接口：

- `resolve(scope): VolumeHandle`
- `resolveRoot(tenantId): { serverRoot, hostRoot }`

它不负责 sandbox 生命周期，也不直接推导容器工作目录。

### `VolumeHandle`

`VolumeHandle` 是一个已经解析好的 server-side volume。

关键字段：

- `scope`
- `serverRoot`
- `hostRoot`
- `publicBaseUrl`

关键方法：

- `ensureRoot()`
- `path(relative?)`
- `publicUrl(relative?)`
- `readFile()`
- `putFile()`
- `deleteFile()`
- `list()`

语义上：

- `serverRoot` 是 server 当前运行环境能直接访问的路径
- `hostRoot` 是 Docker bind mount 需要使用的宿主机路径
- `publicBaseUrl` 是浏览器或前端消费的 URL 前缀

### `WorkspacePathMapper`

`WorkspacePathMapper` 负责把 Volume 映射为 Workspace。

主接口：

- `mapVolumeToWorkspace(volume, options?)`
- `mapWorkspaceToVolume(binding, workspacePath)`

`WorkspaceBinding` 描述一次明确映射：

- `volumeRoot`
- `workspaceRoot`
- `workspacePath`
- `bindSource`
- `containerMountPath`

## 运行时实现

### `DevVolumeClient`

`DevVolumeClient` 表示 server 直接运行在本机文件系统上。

此时：

- `serverRoot === hostRoot`

根路径规则：

- `dev` 且未配置 `SANDBOX_VOLUME` 时使用 `~/data`
- 其他情况下优先使用 `${SANDBOX_VOLUME}/${tenantId}`
- 如果没有 `SANDBOX_VOLUME`，则回退到 `~/data/${tenantId}`

### `DockerVolumeClient`

`DockerVolumeClient` 表示 server 运行在 API Docker 容器内。

此时：

- `serverRoot` 使用 API 容器视角，例如 `/sandbox/${tenantId}`
- `hostRoot` 使用宿主机视角，例如 `${SANDBOX_VOLUME}/${tenantId}`

### `LocalShellWorkspacePathMapper`

本地 shell sandbox 直接工作在 server 可见路径上。

因此：

- `workspaceRoot === volume.serverRoot`
- `workspacePath === volume.serverRoot` 或其子路径

### `DockerWorkspacePathMapper`

Docker sandbox 会把一个精确的 Volume 根挂到容器内 `/workspace`。

因此：

- `bindSource === volume.hostRoot`
- `containerMountPath === '/workspace'`
- `workspaceRoot === '/workspace'`
- `workspacePath` 是把 server 侧子路径映射到 `/workspace/...` 之后的结果

## Runtime Workspace 文件能力

插件、Agent middleware 和异步队列不要直接用 Node `fs` 读取 sandbox 里的 `/workspace/...` 路径。`/workspace` 是 sandbox 视角的工作区挂载点，不一定存在于 API 进程或宿主服务器的同一路径上。

平台通过 runtime capability 提供统一的 workspace 文件入口：

```ts
const workspaceFiles = context.runtime.capabilities.require(WorkspaceFilesRuntimeCapability)
const file = await workspaceFiles.readRuntimeBuffer('/workspace/report.docx')
```

`platform.workspace.files` 支持三类 runtime locator：

- sandbox 绝对路径：`/workspace/report.docx`
- workspace 相对路径：`report.docx` 或 `./report.docx`
- 平台返回的 portable reference：`{ source: 'platform.workspace.files', filePath, workspacePath, catalog, scopeId, ... }`

解析规则：

- `/workspace/foo.docx` 或当前 `workspaceRoot/foo.docx` 会归一化为 Volume 相对路径 `foo.docx`。
- `foo.docx`、`./foo.docx` 按当前 Agent runtime workspace 的相对路径处理。
- 已持久化的 portable reference 会按其中的显式 catalog/scope 读取。
- 默认 scope 由当前 Agent runtime 推断：存在 `projectId` 时使用 `catalog: 'projects'`；否则存在 `xpertId` 时使用 `catalog: 'xperts'` 且 `isolateByUser: false`。

安全规则：

- 拒绝空路径、空字节、`..`、workspace 根目录本身，以及逃逸当前 workspace root 的绝对路径。
- 不在业务插件里硬编码 `/workspace -> /sandbox` 或本机路径映射。
- 不把 `/workspace/...`、`/sandbox/...`、宿主绝对路径持久化为业务文件引用。

读取接口会返回文件 bytes 和平台生成的 `WorkspacePortableFileReference`。需要延迟发送、重试或异步处理时，只持久化 portable reference 以及 `size`、`sha256` 等校验信息，不要把 base64 或 buffer 写入队列 payload。

写入新文件时使用 `writeRuntimeBuffer()`：

```ts
const written = await workspaceFiles.writeRuntimeBuffer({
  path: 'exports/report.docx',
  originalName: 'report.docx',
  mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  buffer
})

await outboundQueue.enqueue({
  fileRef: written.reference,
  size: written.size ?? buffer.length
})
```

这样插件只处理业务文件引用，路径归一化、scope 推断、Volume 读写和租户隔离都由平台统一负责。

## 根路径矩阵

根路径计算集中在 [packages/server-ai/src/shared/volume/volume-layout.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/shared/volume/volume-layout.ts:1)。

### API 容器根

`getApiContainerSandboxVolumeRootPath(tenantId)`：

| 场景                                                    | 返回值                          |
| ------------------------------------------------------- | ------------------------------- |
| `dev` 且未配置 `SANDBOX_VOLUME`                         | `~/data`                        |
| `dev` 且 server 不在 Docker 中，已配置 `SANDBOX_VOLUME` | `${SANDBOX_VOLUME}/${tenantId}` |
| `dev` 且 server 在 Docker 中                            | `/sandbox/${tenantId}`          |
| 非 `dev`                                                | `/sandbox/${tenantId}`          |

### 宿主机根

`getDockerHostSandboxVolumeRootPath(tenantId)`：

| 场景                                   | 返回值                          |
| -------------------------------------- | ------------------------------- |
| `dev` 且未配置 `SANDBOX_VOLUME`        | `~/data`                        |
| 已配置 `SANDBOX_VOLUME`                | `${SANDBOX_VOLUME}/${tenantId}` |
| 未配置 `SANDBOX_VOLUME` 且不是扁平 dev | 回退到 API 容器根               |

### 扁平开发模式

当满足下面条件时，会启用扁平模式：

- `environment.envName === 'dev'`
- 未配置 `SANDBOX_VOLUME`

这时：

- `serverRoot` 和 `hostRoot` 都会直接落到 `~/data`
- 所有逻辑 catalog 共用这一个根目录
- public URL 会经过 `normalizeSandboxPublicVolumeSubpath()` 做兼容归一化

这也是开发环境里 “磁盘根路径” 和 “逻辑 URL 子路径” 看起来不完全对齐的主要原因。

## 逻辑 Volume 子路径

`getVolumeSubpath()` 定义了逻辑子路径：

| `catalog`           | 子路径                             |
| ------------------- | ---------------------------------- |
| `environment`       | `/environment/${environmentId}`    |
| `knowledges`        | `/knowledges/${knowledgeId}`       |
| `projects`          | `/project/${projectId}`            |
| `skills`            | `/skills/${rootId}`                |
| `users`             | `/user/${userId}`                  |
| `workspaces`        | `/workspaces/${workspaceId}`       |
| `xperts` 共享       | `/xpert/${xpertId}`                |
| `xperts` 按用户隔离 | `/xpert/${xpertId}/user/${userId}` |

基于这个子路径，可以得到：

- Volume 路径：`volume.serverRoot`
- Public URL：`getVolumePublicBaseUrl(scope)`

示例：

| 逻辑对象         | server 侧路径                                          | public URL                                            |
| ---------------- | ------------------------------------------------------ | ----------------------------------------------------- |
| 项目             | `/sandbox/${tenantId}/project/${projectId}`            | `/api/sandbox/volume/project/${projectId}`            |
| 用户             | `/sandbox/${tenantId}/user/${userId}`                  | `/api/sandbox/volume/user/${userId}`                  |
| Xpert 共享卷     | `/sandbox/${tenantId}/xpert/${xpertId}`                | `/api/sandbox/volume/xpert/${xpertId}`                |
| Xpert 用户隔离卷 | `/sandbox/${tenantId}/xpert/${xpertId}/user/${userId}` | `/api/sandbox/volume/xpert/${xpertId}/user/${userId}` |
| Workspace        | `/sandbox/${tenantId}/workspaces/${workspaceId}`       | `/api/sandbox/volume/workspaces/${workspaceId}`       |

## Xpert 工作区目录规则

Xpert 运行时工作目录由 `XpertWorkAreaResolver` 统一解析，入口在 `packages/server-ai/src/shared/volume/work-area.ts`。

设计原则：

- 默认工作目录是当前 Xpert / project 的 Volume 根。
- Xpert / project 的根目录就是默认共享工作区；多人共用同一个 Xpert 时，共用文件是预期行为。
- `shared` 仍作为显式共享子目录保留，用于兼容旧逻辑和需要固定共享路径的工具。
- Xpert 长期 FileMemory 是 Xpert 级共享状态，不按用户隔离。
- Agent invoke、terminal、sandbox upload、conversation workspace files、managed sandbox service 都应该使用同一套 resolver 结果，避免 Agent cwd 和终端 cwd 不一致。
- conversation workspace 文件接口必须直接浏览 project / Xpert 的 Volume 根目录，不能再在根目录下额外拼 `users/<userId>` 子树。

标准目录树如下：

```text
/sandbox/<tenantId>/
  project/<projectId>/
    shared/
    agents/<xpertId>/
    sessions/<conversationId>/
    .xpert/

  xpert/<xpertId>/
    shared/
    .xpert/memory/

  user/<userId>/
  environment/<environmentId>/
```

### 项目对话

当 `projectId` 存在时，resolver 使用项目 Volume：

```ts
{
  tenantId,
  catalog: 'projects',
  projectId,
  userId
}
```

相对路径规则：

| 用途                          | Volume 内相对路径           |
| ----------------------------- | --------------------------- |
| 默认 cwd                      | Volume 根                   |
| 项目显式共享子目录            | `shared`                    |
| Xpert 在项目下的 agent 工作区 | `agents/<xpertId>`          |
| 单次会话临时区                | `sessions/<conversationId>` |
| 项目内部状态                  | `.xpert`                    |

本地 shell sandbox 中，`workspace_path` 会是 server 可见路径，例如：

```text
/sandbox/<tenantId>/project/<projectId>
```

Docker sandbox 中，同一目录会映射为：

```text
/workspace
```

### 非项目 Xpert 对话

当没有 `projectId` 且有 `xpertId` 时，resolver 使用共享 Xpert Volume：

```ts
{
  tenantId,
  catalog: 'xperts',
  xpertId,
  userId,
  isolateByUser: false
}
```

相对路径规则：

| 用途                  | Volume 内相对路径 |
| --------------------- | ----------------- |
| 默认 cwd              | Volume 根         |
| Xpert 显式共享子目录  | `shared`          |
| Xpert 长期 FileMemory | `.xpert/memory`   |

因此，多个用户与同一个 Xpert 对话时：

- 文件操作默认落到同一个 `xpert/<xpertId>` 工作区根目录。
- 需要稳定共享子目录时仍可使用 `xpert/<xpertId>/shared`。
- `memory_write`、Dream、memory 文件 API 都读写同一个 `xpert/<xpertId>/.xpert/memory`。

### Docker 容器运行示例

假设：

- `tenantId = tenant-1`
- `userId = user-A`
- `xpertId = xpert-X`
- 非项目对话
- sandbox provider 是 Docker

API 服务容器看到的 server-side Volume 目录是：

```text
/sandbox/tenant-1/xpert/xpert-X/
  shared/
  .xpert/memory/
```

各目录含义：

| 目录                                            | 用途                                    |
| ----------------------------------------------- | --------------------------------------- |
| `/sandbox/tenant-1/xpert/xpert-X`               | Xpert X 的默认工作目录                  |
| `/sandbox/tenant-1/xpert/xpert-X/shared`        | Xpert X 的显式共享子目录                |
| `/sandbox/tenant-1/xpert/xpert-X/.xpert/memory` | Xpert X 的长期 FileMemory，所有用户共享 |

同一个 Volume 在 sandbox Docker 容器里会挂载为 `/workspace`，因此 Agent、terminal 和 sandbox tools 看到的是：

```text
/workspace
/workspace/shared
/workspace/.xpert/memory
```

对应关系：

| API 服务容器路径                                | Sandbox Docker 路径        | 说明                                                   |
| ----------------------------------------------- | -------------------------- | ------------------------------------------------------ |
| `/sandbox/tenant-1/xpert/xpert-X`               | `/workspace`               | 默认 cwd，普通文件生成、文件上传和终端命令默认写入这里 |
| `/sandbox/tenant-1/xpert/xpert-X/shared`        | `/workspace/shared`        | 显式共享子目录                                         |
| `/sandbox/tenant-1/xpert/xpert-X/.xpert/memory` | `/workspace/.xpert/memory` | `memory_write`、Dream、memory 文件 API 的长期记忆目录  |

如果 user B 也与同一个 Xpert X 对话，则默认文件目录仍然是同一个 Xpert 工作区：

```text
/sandbox/tenant-1/xpert/xpert-X
/workspace
```

但 user A 和 user B 共享同一个 Xpert 记忆目录：

```text
/sandbox/tenant-1/xpert/xpert-X/.xpert/memory
/workspace/.xpert/memory
```

如果这次对话属于项目 `project-1`，默认目录会切换到项目 Volume：

```text
/sandbox/tenant-1/project/project-1
/workspace
```

项目共享、agent 和 session 目录分别是：

```text
/sandbox/tenant-1/project/project-1/shared
/sandbox/tenant-1/project/project-1/agents/xpert-X
/sandbox/tenant-1/project/project-1/sessions/<conversationId>
```

在 Docker sandbox 中对应为：

```text
/workspace/shared
/workspace/agents/xpert-X
/workspace/sessions/<conversationId>
```

### Conversation workspace 文件接口

ClawXpert 文件面板以及相关预览 / 编辑流程使用 conversation 文件接口：

```text
GET    /api/chat-conversation/:conversationId/files
GET    /api/chat-conversation/:conversationId/file
POST   /api/chat-conversation/:conversationId/file
POST   /api/chat-conversation/:conversationId/file/upload
DELETE /api/chat-conversation/:conversationId/file
```

这些接口只用 conversation 来解析当前工作区根目录：

| Conversation 上下文               | Volume scope                                           | 子树范围  |
| --------------------------------- | ------------------------------------------------------ | --------- |
| 存在 `projectId`                  | `catalog: 'projects'`, `projectId`                     | Volume 根 |
| 没有 `projectId`，存在 `xpertId`  | `catalog: 'xperts'`, `xpertId`, `isolateByUser: false` | Volume 根 |
| 没有 `projectId` 且没有 `xpertId` | 不允许访问 workspace files                             | N/A       |

因此，非项目 ClawXpert 对话调用 `/api/chat-conversation/:id/files` 时，列出的是共享 Xpert 工作区根目录：

```text
/sandbox/<tenantId>/xpert/<xpertId>
/workspace
```

`conversationId` 仍用于会话定位和权限上下文，但不再作为文件系统目录片段。同一个 Xpert 的多个用户会看到同一份 workspace 文件。

### Workspace 文件相对路径解析

插件、managed connection 和 runtime capability 写入 workspace 文件时，应把文件记录为“相对路径 + catalog/scope”，而不是记录当前进程或 sandbox 里的绝对路径。

标准输入形态：

```ts
{
  tenantId,
  userId,
  catalog: 'projects' | 'users' | 'knowledges' | 'skills' | 'xperts',
  scopeId,
  filePath: 'files/wechat/<integrationId>/<messageId>/<fileName>'
}
```

公共解析函数位于：

```text
packages/server-ai/src/file-understanding/domain/workspace-file.ts
```

它们负责把 `catalog` / `scopeId` 统一映射为 `VolumeScope`：

| catalog      | scope 字段                 | Volume scope 输出                                       |
| ------------ | -------------------------- | ------------------------------------------------------- |
| `projects`   | `projectId` 或 `scopeId`   | `{ catalog: 'projects', projectId, userId }`            |
| `xperts`     | `xpertId` 或 `scopeId`     | `{ catalog: 'xperts', xpertId, userId, isolateByUser }` |
| `users`      | `scopeId` 或 `userId`      | `{ catalog: 'users', userId }`                          |
| `knowledges` | `knowledgeId` 或 `scopeId` | `{ catalog: 'knowledges', knowledgeId, userId }`        |
| `skills`     | `rootId` 或 `scopeId`      | `{ catalog: 'skills', rootId, userId }`                 |

读取文件时，调用方只应把相对路径交给 `VolumeHandle.path(relativePath)`：

```ts
const { volumeScope } = resolveWorkspaceVolumeScope(input, { tenantId, userId })
const absolutePath = volumeClient.resolve(volumeScope).path(input.filePath)
```

不要把 `files/wechat/...` 直接交给 Node `fs.open()`；那会按 API 进程 `cwd` 解析，在生产环境中容易出现文件存在于 workspace 面板但后端报 `ENOENT` 的情况。也不要把 `/workspace/...`、`/sandbox/...` 或本机 runtime 目录持久化为业务路径；这些路径属于具体 runtime / sandbox 映射，跨部署不可移植。

### 环境 sandbox

当 `sandboxEnvironmentId` 存在时，环境 sandbox 优先级最高，会忽略项目上下文：

```ts
{
  tenantId,
  catalog: 'environment',
  environmentId,
  userId
}
```

环境 Volume 的默认 cwd 仍然是环境根目录，即 `environment/<environmentId>`。这类目录用于显式环境级 sandbox，不参与项目 / Xpert 用户目录划分。

### Xpert FileMemory

Xpert FileMemory 的 canonical root 固定为：

```text
xpert/<xpertId>/.xpert/memory
```

也就是 server 侧完整路径：

```text
/sandbox/<tenantId>/xpert/<xpertId>/.xpert/memory
```

注意：

- 不再使用 `.xpert/memory/xperts/<xpertId>` 作为新写入路径。
- frontmatter 中仍保留 `scopeId=<xpertId>`，用于表达记忆属于哪个 Xpert。
- FileMemory volume scope 使用 `catalog: 'xperts'` 与 `isolateByUser: false`。
- 首次访问时会从旧路径 `.xpert/memory/xperts/<xpertId>` 做非破坏性 lazy migration：只复制新目录缺失的文件，冲突时保留新文件，并在 `.dream/migrations/` 写 migration report。

## Knowledge 工作区目录规则

知识库和知识流水线使用独立的 `KnowledgeWorkAreaResolver`。它与 Xpert 工作区共享 Volume / Workspace 映射机制，但不并入 Xpert 对话默认 cwd。

设计原则：

- 知识库是 tenant 内的共享数据资产，目录粒度是 `knowledgebaseId`。
- 多个用户、多个 Xpert、多个 pipeline 可以读写同一个知识库 Volume。
- 用户私有对话目录和 Xpert FileMemory 不存放知识库源文件。
- pipeline 的临时文件优先按单次 task 隔离，避免多个导入任务共用同一个 `tmp`。

标准目录树如下：

```text
/sandbox/<tenantId>/
  knowledges/<knowledgebaseId>/
    files/
    users/<userId>/staging/
    documents/<documentId>/
    pipeline/runs/<taskId>/
    tmp/
    .knowledge/
```

相对路径规则：

| 用途                         | Volume 内相对路径            |
| ---------------------------- | ---------------------------- |
| 知识库正式源文件             | `files`                      |
| 用户导入暂存区               | `users/<userId>/staging`     |
| 单文档派生资产               | `documents/<documentId>`     |
| 单次 pipeline run 工作区     | `pipeline/runs/<taskId>`     |
| 单次 pipeline run 临时目录   | `pipeline/runs/<taskId>/tmp` |
| 兼容旧逻辑的临时目录         | `tmp`                        |
| 知识库内部状态、锁、迁移报告 | `.knowledge`                 |

### 知识库上传

新的知识库文件上传入口会继续使用 `catalog: 'knowledges'` 与 `knowledgeId` 定位 Volume，但文件会写入 `files/` 子目录。

示例：

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/files/<folder>/manual-<unix>-<random>.pdf
```

返回给前端和文档记录的 `filePath` 是相对 knowledge Volume 的路径，例如：

```text
files/<folder>/manual-<unix>-<random>.pdf
```

读取时仍然通过 `volume.path(doc.filePath)` 解析，因此历史上没有 `files/` 前缀的旧文档路径仍可继续读取。

### 知识流水线

知识流水线 processor / transformer 会使用同一个 knowledge Volume：

```ts
{
  tenantId,
  catalog: 'knowledges',
  knowledgeId,
  userId
}
```

当存在 `taskId` 时，默认 run 工作区是：

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/pipeline/runs/<taskId>
```

transformer 临时目录是：

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/pipeline/runs/<taskId>/tmp
```

没有 `taskId` 但有 `documentId` 的普通文档处理，会使用：

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/documents/<documentId>/tmp
```

没有 task/document 上下文的预览场景仍回退到：

```text
/sandbox/<tenantId>/knowledges/<knowledgebaseId>/tmp
```

Docker sandbox 中，如果未来把知识库 pipeline 映射到容器执行，同一 Volume 会挂载为 `/workspace`：

```text
/workspace/files
/workspace/pipeline/runs/<taskId>
/workspace/pipeline/runs/<taskId>/tmp
/workspace/.knowledge
```

### Runtime `sys` 字段

Agent 运行时会把 resolver 结果写入 `sys`：

| 字段                     | 含义                                               |
| ------------------------ | -------------------------------------------------- |
| `volume`                 | server 侧 Volume 根路径                            |
| `workspace_path`         | sandbox 侧默认 cwd                                 |
| `workspace_url`          | 默认 cwd 对应的 public URL                         |
| `workspace_root`         | sandbox 侧挂载根                                   |
| `shared_workspace_path`  | 共享工作区路径                                     |
| `agent_workspace_path`   | 项目内 Xpert agent 工作区路径，仅项目场景有值      |
| `session_workspace_path` | 单次会话临时区路径，仅项目且有 conversation 时有值 |
| `memory_workspace_path`  | Xpert 长期记忆路径，仅非项目 Xpert 场景有值        |

`workspace_path` / `workspace_url` / `volume` 是兼容字段，不能删除。新代码如果需要共享目录、agent 目录、session 目录或 memory 目录，应优先读取新的明确字段。

## 还有几类额外路径

### Workspace 路径

[packages/server-ai/src/xpert-workspace/types.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/xpert-workspace/types.ts:1)：

- workspace 根目录：`${volumeRoot}/workspaces/${workspaceId}`

[packages/server-ai/src/skill-repository/types.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/skill-repository/types.ts:1)：

- workspace skills 根目录：`${workspaceRoot}/skills`

### 组织共享技能路径

同样在 [packages/server-ai/src/skill-repository/types.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/skill-repository/types.ts:1)：

- 组织级共享技能根：`${volumeRoot}/organizations/${organizationId}/skills/public`
- 全局共享技能根：`${volumeRoot}/skills/public`

## 从工作区 Resolver 映射到 Workspace

普通 Volume 场景仍然可以手动两步处理：

1. 先解析 Volume
2. 再映射 Workspace

但 Xpert 对话、项目对话、terminal、sandbox upload、managed service、Agent invoke 等运行态入口应优先使用 `XpertWorkAreaResolver`。

推荐写法：

```ts
const workArea = await this.workAreaResolver.resolve({
  tenantId,
  userId,
  provider,
  xpertId,
  projectId,
  conversationId,
  environmentId
})
```

结果含义：

- `workArea.volumePath` 供 server 识别 Volume 根
- `workArea.workspaceBinding` 供 sandbox provider 挂载
- `workArea.workingDirectory` 供 sandbox 作为工作目录使用
- `workArea.sharedPath` / `agentPath` / `sessionPath` / `memoryPath` 供业务显式使用

## 调用链里的字段语义

新架构下，几个常见字段的语义应该固定下来：

- `sys.volume`: server 侧 Volume 路径
- `sys.workspace_path`: sandbox 侧 Workspace 路径
- `sandbox.workingDirectory`: sandbox 侧 Workspace 路径
- `workspaceBinding`: Volume 和 Workspace 的显式映射

不要再把 `/sandbox/...` 的 server 路径直接塞到 Docker sandbox 的 `workingDirectory` 里。

## Skills middleware fallback sandbox

Skills middleware 正常情况下会从 `request.runtime.configurable.sandbox` 读取当前运行态 sandbox。如果工具调用需要 backend，但运行态 sandbox 缺失，fallback sandbox 也必须遵循同一套 workspace 归属规则：

| 上下文                           | Fallback Volume scope                                  |
| -------------------------------- | ------------------------------------------------------ |
| 存在 `projectId`                 | `catalog: 'projects'`, `projectId`                     |
| 没有 `projectId`，存在 `xpertId` | `catalog: 'xperts'`, `xpertId`, `isolateByUser: false` |
| 两者都不存在                     | `catalog: 'users'`, `userId`                           |

这样可以避免 skill 文件读取或 skill shell 执行时，ClawXpert 运行突然退回到私有的 `user/<userId>` Volume。

## 文件上传语义

和新架构直接相关的是三种目标：

### `volume`

- 目标路径是 server 侧 Volume
- 返回 public URL

### `sandbox:mounted_workspace`

- `workspacePath` 现在表示 server 可见的挂载路径
- 本质上是直接写入已经挂载到 server 的 Volume

### `sandbox:backend_upload`

- `workspaceId` / backend `workspace_id` 表示 sandbox 侧工作目录
- 实际文件写入发生在 sandbox backend 内

这三类路径不要混用。

## `VolumeSubtreeClient`

旧的 `WorkspaceVolumeClient` 现在重定责为 `VolumeSubtreeClient`，定义在 [packages/server-ai/src/shared/volume/volume-subtree.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/shared/volume/volume-subtree.ts:1)。

它的职责不是“代表一个 Workspace”，而是：

- 在一个 `VolumeHandle` 下面浏览某个子树
- 读写受限目录中的文本文件
- 生成相对于该子树的文件 URL

适合用在：

- conversation workspace 文件接口，此时 project / Xpert 的 Volume 根就是受控子树
- xpert memory 文件目录
- 项目或 workspace 下的受控子目录

## 依赖注入入口

新架构默认通过 [packages/server-ai/src/shared/volume/volume.module.ts](/Users/xpertai03/Pro/data/xpert/packages/server-ai/src/shared/volume/volume.module.ts:1) 注入：

- `VOLUME_CLIENT`
- `WorkspacePathMapperFactory`
- `XpertWorkAreaResolver`
- `KnowledgeWorkAreaResolver`

推荐写法：

```ts
@Inject(VOLUME_CLIENT)
private readonly volumeClient: VolumeClient
```

Xpert 运行态入口推荐直接注入 resolver：

```ts
constructor(
  private readonly workAreaResolver: XpertWorkAreaResolver
) {}
```

知识库和知识流水线入口推荐直接注入：

```ts
constructor(
  private readonly knowledgeWorkAreaResolver: KnowledgeWorkAreaResolver
) {}
```

而不是继续在业务代码里手动拼接 `project/users/agents/sessions/.xpert/memory` 或 `knowledges/files/tmp`。

## 推荐实践

- 先确定你需要的是 Volume 还是 Workspace。
- 需要 server 读写时用 `VolumeHandle`。
- 需要 sandbox 执行路径时先通过 `WorkspacePathMapper` 映射。
- 需要 Xpert / project / environment 运行目录时用 `XpertWorkAreaResolver`。
- 需要知识库上传、知识流水线或文档派生资产目录时用 `KnowledgeWorkAreaResolver`。
- 需要浏览受控子目录时用 `VolumeSubtreeClient`。
- 保留旧 static helper 只做兼容，不要把它们当作新的主入口。
