docs/plan/2026-07-01-cloud-workspace-project-design.md
Rama main · réplica de lectura del repositorio original.
# 云端模式可选工作区(项目空间)设计方案
> 版本:v1.0 | 日期:2026-07-01 | 状态:待评审
## 1. 背景与问题
### 1.1 现状
| 执行模式 | 工作区来源 | 路径规则 | 多会话共享 |
|---------|-----------|---------|-----------|
| **LOCAL** | 用户通过 Electron 目录选择器指定 | 客户端本地绝对路径 | ✅ 同一路径可创建多个会话 |
| **CLOUD** | 后端自动生成,客户端不可指定 | `{workspace-root}/{userId}/{sessionId}` | ❌ 每个会话独立目录 |
当前云端模式的设计初衷是**会话级隔离**:每个任务拥有独立沙箱,避免文件互相污染。实际上线后发现,用户经常在同一项目下开展多个任务(修 bug、写文档、代码审查等),每次都生成全新工作区导致:
- 无法复用已 clone 的代码仓库、已安装的依赖
- 无法在多个会话间共享项目上下文(文件、配置、`.mao/skills` 同步结果)
- 左侧任务列表所有云端会话堆在「云端工作区」单一分组下,缺乏项目维度组织
### 1.2 目标
在**避免大逻辑改动**的前提下,为云端模式增加**可选**的项目工作区能力:
1. **默认行为不变**:不指定项目时,仍为每会话自动生成隔离目录
2. **可选共享**:用户可指定项目名称,多个会话复用同一服务端目录
3. **复用现有链路**:`Session.workspace` 字段、工具执行、Skill 同步、文件引用等逻辑尽量不改
4. **安全可控**:项目目录严格限制在用户沙箱内,防止路径穿越
---
## 2. 现状代码分析(可复用能力)
好消息:**后端核心链路已支持按 `Session.workspace` 执行**,缺口主要在「云端自动隔离策略」和「前端未暴露选项」。
### 2.1 已有能力
```text
Session.workspace (VARCHAR 512)
↓
SessionService.createSession()
→ CLOUD 且 workspace 为空 → 自动生成 {root}/{userId}/{sessionId}
→ CLOUD 且 workspace 非空 → 直接使用(当前无校验!)
↓
PathSandbox.resolve(userPath, sessionWorkspace)
ToolDispatcher / ReadFile / Shell / SkillSyncService
→ 均以 session.workspace 为根目录
```
关键代码位置:
| 模块 | 文件 | 说明 |
|------|------|------|
| 会话创建 | `SessionService.java:105-115` | 仅 workspace 为空时自动生成 |
| 路径沙箱 | `PathSandbox.java` | 以 session workspace 为 root 解析相对路径 |
| Skill 同步 | `SkillSyncService.java` | 同步到 `{workspace}/.mao/skills/` |
| 文件列表 | `FileController.workspace-list` | 按 sessionId 推导 workspace |
| 前端创建 | `session.ts:createSession()` | 已传 `workspace` 字段 |
| 任务分组 | `TaskIndexPanel.vue:266-278` | CLOUD 全部归入 `key='CLOUD'` 单组 |
### 2.2 当前缺口
| 缺口 | 影响 |
|------|------|
| 前端 CLOUD 模式隐藏工作区选择 | 用户无法指定项目 |
| `TaskIndexPanel.onGroupNewTask` 刻意不传 CLOUD workspace | 分组内新建任务无法继承项目 |
| CLOUD 指定 workspace 时无服务端校验 | 存在安全隐患(可传入任意路径) |
| 任务列表 CLOUD 不按 workspace 分组 | 多项目时组织混乱 |
---
## 3. 核心设计:云端项目(Cloud Project)
### 3.1 概念模型
引入用户可理解的 **项目标识(project slug)**,由后端解析为服务端目录:
```text
workspace-root/ # 配置项 app.harness.workspace-root
└── {userId}/ # 用户沙箱边界
├── {sessionId}/ # 默认:会话隔离目录(现有行为)
└── projects/
└── {slug}/ # 可选:命名项目目录(多个会话共享)
├── .mao/skills/
└── <项目文件>
```
**路径规则**:
| 场景 | 客户端传入 | 服务端解析后的 `Session.workspace` |
|------|-----------|-----------------------------------|
| 默认(不指定项目) | 无 / 空 | `{root}/{userId}/{sessionId}` |
| 指定项目 | `cloudProjectKey: "my-app"` | `{root}/{userId}/projects/my-app` |
使用 `projects/` 子目录的原因:
- 避免项目 slug 与 `sessionId`(自增数字)路径冲突
- 一眼区分「隔离会话目录」与「命名项目目录」
- 现有已创建的 `{userId}/{sessionId}` 目录无需迁移
### 3.2 与 LOCAL 模式的字段语义区分
复用 `Session.workspace` 存储**服务端绝对路径**(与现有一致),通过执行模式区分入参语义:
| 模式 | 创建会话时 `workspace` 请求字段含义 | 存储值 |
|------|-----------------------------------|--------|
| LOCAL | 客户端本地绝对路径 | 原样存储,工具转发到 Electron |
| CLOUD | **不通过 workspace 字段传入**(避免混淆) | 由 `cloudProjectKey` 解析生成 |
建议新增专用请求字段 `cloudProjectKey`(可选),而不是让前端拼接服务端绝对路径。
### 3.3 projectKey 字段
`Session.projectKey` 已存在,当前由 `deriveProjectKey(workspace)` 取路径最后一段生成。
| workspace 路径 | projectKey |
|---------------|------------|
| `.../1/42`(隔离) | `42` |
| `.../1/projects/my-app`(共享) | `my-app` |
共享项目场景下,`projectKey` 自然成为项目展示名,可直接用于任务列表分组标签。
---
## 4. 接口设计
### 4.1 创建会话(变更)
`POST /api/v1/sessions`
在现有 `CreateSessionRequest` 上**新增可选字段**:
```json
{
"agentId": 1,
"executionMode": "CLOUD",
"cloudProjectKey": "agent-workbench-mimo",
"modelId": 2
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `cloudProjectKey` | String | 否 | 云端项目名称,仅 `executionMode=CLOUD` 时生效 |
| `workspace` | String | 否 | **LOCAL 模式**使用;CLOUD 模式忽略此字段 |
**服务端解析逻辑**(`SessionService.createSession` 改造):
```java
if ("CLOUD".equals(executionMode)) {
if (cloudProjectKey != null && !cloudProjectKey.isBlank()) {
String slug = CloudWorkspaceResolver.normalizeAndValidate(cloudProjectKey);
String path = pathSandbox.getWorkspaceRoot()
.resolve(String.valueOf(userId))
.resolve("projects")
.resolve(slug)
.toString();
new File(path).mkdirs();
session.setWorkspace(path);
session.setProjectKey(slug);
} else if (workspace == null || workspace.isBlank()) {
// 保持现有逻辑:{userId}/{sessionId}
...
}
}
```
### 4.2 路径校验(新增)
新增 `CloudWorkspaceResolver`(或扩展 `PathSandbox`):
```java
public final class CloudWorkspaceResolver {
private static final Pattern SLUG_PATTERN =
Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$");
// 保留字,避免与目录结构冲突
private static final Set<String> RESERVED = Set.of("projects", "sessions");
public static String normalizeAndValidate(String raw) { ... }
public static void assertUnderUserSandbox(PathSandbox sandbox, Long userId, String workspace) {
Path expectedPrefix = sandbox.getWorkspaceRoot().resolve(String.valueOf(userId));
Path resolved = Paths.get(workspace).toAbsolutePath().normalize();
if (!resolved.startsWith(expectedPrefix)) {
throw new BusinessException(ErrorCode.PARAM_INVALID, "工作区路径非法");
}
}
}
```
**slug 规则**:
- 长度 1–64
- 字符集:`[a-zA-Z0-9_-]`,首字符必须为字母或数字
- 不允许纯 `.` / `..`
- 保留字:`projects`、`sessions`(可扩展)
---
## 5. 前端设计
### 5.1 新建任务 UI(`ChatInput.vue`)
云端模式下,在模式选择器旁增加**可选**「项目」输入,而非本地目录选择器:
```text
┌─────────────────────────────────────────────────────────┐
│ [Agent 选择器] │
│ (●) 云端模式 ( ) 本地模式 [项目: my-app ▼] ← 可选 │
│ 留空 = 临时工作区 │
├─────────────────────────────────────────────────────────┤
│ 输入消息... │
└─────────────────────────────────────────────────────────┘
```
交互说明:
| 元素 | 行为 |
|------|------|
| 项目输入框 | 可选填;留空则行为与现在完全一致 |
| 下拉建议 | 从 `sessionStore.sessions` 客户端聚合历史 `cloudProjectKey` |
| 新建项目 | 直接输入新 slug,首条消息发送时创建目录 |
| 本地模式 | 仍显示目录选择器,逻辑不变 |
**与 LOCAL 的区别**:云端选的是**项目名**,不是文件夹路径;不需要 Electron `selectDirectory`。
### 5.2 发送逻辑(`useChat.ts` / `session.ts`)
```typescript
await sessionStore.createSession(agentId, executionMode, workspace, env, modelId, permissionLevel, cloudProjectKey)
```
- `executionMode === 'LOCAL'` → 传 `workspace`(本地路径)
- `executionMode === 'CLOUD'` → 传 `cloudProjectKey`(可选),不传 `workspace`
### 5.3 任务列表分组(`TaskIndexPanel.vue`)
将 CLOUD 分组逻辑与 LOCAL 对齐:
```typescript
// 改造前
if (s.executionMode === 'CLOUD') {
key = 'CLOUD'
}
// 改造后
if (s.executionMode === 'CLOUD') {
if (s.workspace?.includes('/projects/')) {
key = `CLOUD:${s.workspace}`
} else {
key = 'CLOUD:临时工作区' // 或未指定项目的会话
}
}
```
`formatGroupLabel`:
- `CLOUD:临时工作区` → 「云端 · 临时工作区」
- `CLOUD:/data/.../projects/my-app` → 「云端 · my-app」
### 5.4 分组内新建任务(`onGroupNewTask`)
```typescript
// 改造前:CLOUD 不传 workspace
workspace: last.executionMode === 'LOCAL' ? last.workspace : undefined,
// 改造后:继承 cloudProjectKey
cloudProjectKey: last.executionMode === 'CLOUD' && isSharedCloudProject(last)
? last.projectKey
: undefined,
workspace: last.executionMode === 'LOCAL' ? last.workspace : undefined,
```
辅助函数 `isSharedCloudProject(session)`:`workspace` 包含 `/projects/` 即为共享项目。
### 5.5 工具栏工作区指示器(`ChatInput.vue`)
云端模式当前固定显示「云端工作区」,建议细化为:
- 临时工作区:「云端 · 独立」
- 共享项目:「云端 · {projectKey}」
---
## 6. 要做
### 6.1 后端
| 文件 | 改动 |
|------|------|
| `CloudWorkspaceResolver.java`(新增) | slug 校验 + 用户沙箱边界断言 |
| `SessionService.java` | 解析 `cloudProjectKey` → `{userId}/projects/{slug}`;未指定时保持 `{userId}/{sessionId}` |
| `SessionController.java` | `CreateSessionRequest` 增加可选字段 `cloudProjectKey` |
### 6.2 前端
| 文件 | 改动 |
|------|------|
| `ChatInput.vue` | CLOUD 模式增加可选项目输入/下拉;工具栏指示器区分「独立」与「{projectKey}」 |
| `session.ts` | `createSession` 增加 `cloudProjectKey` 参数并传给 API |
| `useChat.ts` | LOCAL 传 `workspace`,CLOUD 传 `cloudProjectKey`(可选) |
| `TaskIndexPanel.vue` | CLOUD 按项目分组;`onGroupNewTask` 继承共享项目的 `projectKey` |
历史项目下拉建议:从 `sessionStore.sessions` 客户端聚合(`workspace` 含 `/projects/` 的 distinct `projectKey`),无需新增 API。
### 6.3 行为与约束(一并交付)
- 默认不填项目名 → 行为与现网完全一致(per-session 隔离目录)
- 填写项目名 → 多会话共享 `{userId}/projects/{slug}`
- slug 非法或越界 → 400
- 删除会话不删除 workspace 目录(与现状一致)
- 数据库无需迁移;旧客户端不传 `cloudProjectKey` 不受影响
### 6.4 测试验收
1. CLOUD 不传 `cloudProjectKey` → workspace = `{userId}/{sessionId}`
2. 传 `cloudProjectKey` → workspace = `{userId}/projects/{slug}`,目录自动创建
3. 两个会话同一 `cloudProjectKey` → `workspace` 相同,文件可共享
4. 非法 slug(`../etc`、`a/b`、超长、保留字)→ 400
5. 跨用户目录隔离
6. ReadFile / WriteFile / Shell / Skill 同步在项目目录内正常
7. 任务列表分组正确;分组内新建继承项目
8. 旧客户端兼容
---
## 7. 不做
### 7.1 产品能力
| 项 | 说明 |
|----|------|
| 云端工作区映射本地目录 | LOCAL 模式职责 |
| 跨用户共享项目空间 | 沙箱按 `userId` 隔离 |
| 项目管理 CRUD | 无独立项目实体;无创建/删除/重命名项目 API |
| 删除会话时清理 workspace 目录 | 与现状一致,目录保留 |
| 项目目录清理/归档运维工具 | 超出本次范围 |
| 云端文件浏览器 | 见 `2026-06-25-workspace-file-browser-design.md`,V2 范围 |
| 管理后台展示云端项目路径 | admin 无改动 |
| CLOUD 模式客户端传绝对路径 | 统一由 `cloudProjectKey` 解析,禁止直传 `workspace` |
### 7.2 接口与模块
| 项 | 说明 |
|----|------|
| `GET /sessions/cloud-projects` | 历史项目由前端从会话列表聚合 |
| `cloud_project` 数据表 | 复用 `session.workspace` / `session.project_key` |
| Flyway 迁移 | 无 schema 变更 |
| `NewTaskDialog.vue` | 已废弃,新建流程走 `ChatInput`,不改动 |
| `PathSandbox.resolve()` 额外边界校验 | `CloudWorkspaceResolver` 创建时已校验,防御纵深非必须 |
### 7.3 已有模块(保持不动)
以下模块已按 `Session.workspace` 工作,**无需修改**:
- `ToolDispatcher` / 各 Tool 实现
- `SkillSyncService`
- `ShellSessionManager`
- `StreamingWsHandler`
- `FileController.workspace-list`
- `PromptEngine`
---
## 8. 安全与隔离
### 8.1 用户沙箱边界
```text
合法路径必须满足:
normalize(workspace).startsWith(normalize(workspace-root / userId /))
```
禁止:
- 客户端直接传入绝对路径指定 CLOUD workspace(统一由 `cloudProjectKey` 解析)
- slug 包含 `..`、`/`、`\`
- 访问其他 `userId` 目录
### 8.2 并发会话同项目
多个 CLOUD 会话共享同一 `projects/{slug}` 时:
| 风险 | 应对 |
|------|------|
| 同时写同一文件 | 与 LOCAL 多会话同目录一致,接受最终一致性;可在文档中提示用户避免并行修改同一文件 |
| Shell 会话 cwd 冲突 | 各会话 Shell 独立(`ShellSessionManager` 按 sessionId 隔离),cwd 默认均为同一 workspace root,行为合理 |
| Skill 同步竞争 | `SkillSyncService` 按 `agentId:workspace` 维护 syncState,同 workspace 共享状态,反而减少重复同步 |
### 8.3 删除会话
删除会话不删除 workspace 目录(与现状一致,见 §7 不做)。
---
## 9. 兼容性与迁移
| 项 | 说明 |
|----|------|
| 数据库 | **无需迁移**,复用 `workspace`、`project_key` 字段 |
| 存量会话 | 路径仍为 `{userId}/{sessionId}`,行为不变 |
| API 兼容 | `cloudProjectKey` 为新增可选字段,旧客户端不传则走原逻辑 |
| 配置 | 无新增配置项 |
---
## 10. 用户流程示例
### 10.1 首次围绕项目开展工作
```text
1. 新建任务 → 云端模式 → 项目填 "agent-workbench-mimo" → 发送首条消息
2. 后端创建 workspace: /opt/mao/data/workspace/1/projects/agent-workbench-mimo
3. Agent 在该目录执行 clone、读写文件、shell 等
4. Skill 同步到 .../agent-workbench-mimo/.mao/skills/
```
### 10.2 同项目第二个任务
```text
1. 左侧分组「云端 · agent-workbench-mimo」→ 点击分组内「新建」
2. 自动继承 cloudProjectKey = "agent-workbench-mimo"
3. 新会话复用同一目录,共享代码与配置
```
### 10.3 一次性临时任务(默认)
```text
1. 新建任务 → 云端模式 → 项目留空 → 发送
2. 后端创建 .../1/43(sessionId=43),与其他会话隔离
```
---
## 11. 方案对比(为何选此方案)
| 方案 | 改动量 | 优点 | 缺点 |
|------|--------|------|------|
| **A. cloudProjectKey → projects/{slug}(推荐)** | 小 | 语义清晰、安全、兼容好 | 需新增一个 API 字段 |
| B. 复用 workspace 传服务端绝对路径 | 最小 | 后端几乎不改 | 前端需知服务端路径,不安全、体验差 |
| C. 新建 cloud_project 表 | 大 | 完整项目管理 | 过度设计,与「避免大改」目标不符 |
| D. 取消自动隔离,全部改为必选项目 | 中 | 模型简单 | 破坏现有默认体验,强迫用户每次命名 |
---
## 12. 总结
本方案的核心思路是:**不改变「workspace 驱动工具执行」的现有架构**,仅在会话创建时增加一条可选分支——将用户输入的项目名解析为 `{userId}/projects/{slug}` 目录。默认仍为 per-session 隔离,指定项目后才进入共享模式。
整体改动见 §6 要做;§7 列出的能力与模块均不在本次范围内。核心执行路径(工具链、Skill 同步、WebSocket 等)无需重构。