圆桌会议讨论结果,2026-03-29
- 一个用户可以有 N 个 runner(不同机器/不同类型)
- 用户可以在前端选择当前活跃 runner
- 不同 runner 可以有不同 sandbox 模式(docker/native/remote)
- 代码架构清晰简洁,改动范围最小
tools/remote_sandbox.go的connections sync.Map:key 是userID,一个用户只能有一个 runnertools/runner_tokens.go的 DB 表runner_tokens(id, user_id, token, name, created_at):user_id是 PRIMARY KEY(一对一)- Runner 连接时通过 WebSocket 注册,服务端用 userID 作为唯一 key
- 前端 SettingsPanel 只有一个 runner token 区域
| 文件 | 职责 |
|---|---|
tools/remote_sandbox.go | Runner WebSocket 服务端,连接管理 |
tools/runner_tokens.go | Token 生成/验证,DB 操作 |
tools/sandbox_router.go | Sandbox 路由,按 senderID 找到对应 Sandbox |
tools/runner_protocol.go | Runner 协议消息定义 |
channel/web_api.go | 前端 API 端点 |
cmd/runner/ | Runner 端(独立二进制) |
否决方案:改 connections key 为 userID:runnerName
- 竞态风险,影响面大,SandboxRouter 所有调用点都要改
采纳方案:加一层 userRunners 映射
connections sync.Map
key: userID (string)
value: *userRunnersEntry {
mu sync.RWMutex
runners map[string]*runnerConnection // runnerName → conn
active string // 当前活跃 runnerName
}
v17 migration:新建 runners 表,迁移 runner_tokens 数据
CREATE TABLE IF NOT EXISTS runners (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
mode TEXT NOT NULL DEFAULT 'native', -- native/docker
docker_image TEXT NOT NULL DEFAULT 'ubuntu:22.04',
workspace TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, name)
);
-- 迁移旧数据
INSERT OR IGNORE INTO runners (user_id, name, token, mode, workspace, created_at)
SELECT user_id, 'default', token, 'native', '', created_at
FROM runner_tokens;
活跃状态:复用 user_settings 表
INSERT OR IGNORE INTO user_settings (user_id, key, value)
SELECT user_id, 'active_runner', 'default'
FROM runner_tokens;
// tools/remote_sandbox.go
type userRunnersEntry struct {
mu sync.RWMutex
runners map[string]*runnerConnection // runnerName → conn
active string // 当前活跃的 runnerName
}
// connections 改名为 userRunners
// key: userID (string)
// value: *userRunnersEntry
userRunners sync.Map
// 活跃状态持久化(接口)
activeStore ActiveRunnerStore // 底层用 user_settings 表
// internal/runnerproto/runner_proto.go
type RegisterRequest struct {
UserID string `json:"user_id"`
AuthToken string `json:"auth_token"`
Workspace string `json:"workspace,omitempty"`
Shell string `json:"shell,omitempty"`
RunnerName string `json:"runner_name,omitempty"` // 新增,可选
}
服务端注册流程:
- 解析 userID from URL:
/ws/{userID} - Upgrade + 读注册消息
- 验证 token → 查
runners表得到 runnerName - 如果请求带
runner_name且不匹配 DB → 拒绝 - 如果请求不带
runner_name→ 用 DB 中的 name(兼容旧 runner) - 存入
userRunners - 如果该用户没有 active runner → 设为当前 runner
func (rs *RemoteSandbox) getActiveRunner(userID string) (*runnerConnection, error) {
val, ok := rs.userRunners.Load(userID)
if !ok {
return nil, fmt.Errorf("no runner connected for user %q", userID)
}
entry := val.(*userRunnersEntry)
entry.mu.RLock()
defer entry.mu.RUnlock()
// 优先用 active
if conn, ok := entry.runners[entry.active]; ok {
return conn, nil
}
// fallback: 用第一个可用的
for _, conn := range entry.runners {
return conn, nil
}
return nil, fmt.Errorf("no runner connected for user %q", userID)
}
| Method | Path | 说明 |
|---|---|---|
GET | /api/runners | 列出所有 runner(含在线状态) |
POST | /api/runners | 创建 runner |
DELETE | /api/runners/{name} | 删除 runner |
PUT | /api/runners/{name}/active | 设为活跃 |
GET | /api/runners/active | 获取当前活跃 runner |
向后兼容端点(保留一个版本后移除):
| Method | Path | 映射到 |
|---|---|---|
GET | /api/runner/token | GET /api/runners + 取 active 的 command |
POST | /api/runner/token | POST /api/runners with name=“default” |
DELETE | /api/runner/token | DELETE /api/runners/default |
格式不变。Token 已经是 per-runner 唯一的,server 靠 token 查 DB 确定 runner 身份。
# 旧命令(仍然工作)
./xbot-runner --server ws://host/ws/web-5 --token abc123
# 新命令(格式一样)
./xbot-runner --server ws://host/ws/web-5 --token abc123
┌─────────────────────────────────────────┐
│ 🖥️ 工作环境 │
├─────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐ │
│ │ 🟢 MacBook Pro [活跃] [⋯] │ │ ← 绿点=在线,活跃标记
│ │ 本地开发 · ~/workspace │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ ⚫ Build Server [⋯] │ │ ← 黑点=离线
│ │ Docker · ubuntu:22.04 │ │
│ └─────────────────────────────────┘ │
│ │
│ [+ 添加工作环境] │ │
│ │
│ ┌─ 快速模板 ─────────────────────┐ │
│ │ 📦 本地开发 🐳 Docker隔离 │ │
│ │ ✏️ 自定义 │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
交互:
- 点击卡片 → 设为活跃(如果在线)
⋯菜单 → 复制连接命令 / 编辑 / 删除- 删除时:如果 runner 在线,先断开连接
切换确认(agent 正在工作时):
⚠️ Agent 正在工作
切换后,当前操作将在原环境完成,后续操作将在新环境执行。
[取消] [确认切换]
| 步骤 | 文件 | 改动 |
|---|---|---|
| 1.1 | storage/sqlite/db.go | v17 migration: create runners table, migrate data |
| 1.2 | tools/runner_tokens.go | 重写为 RunnerTokenStore,新增 FindByToken/List/Create/Delete |
| 1.3 | internal/runnerproto/runner_proto.go | RegisterRequest 加 RunnerName 字段 |
| 1.4 | tools/remote_sandbox.go | connections → userRunners,新增 getActiveRunner,SetActive |
| 1.5 | tools/sandbox_router.go | 微调路由逻辑(HasUser 改为检查 userRunnersEntry) |
| 1.6 | channel/web_api.go | 新增 /api/runners 系列端点,保留旧端点兼容 |
| 1.7 | main.go | 更新 callbacks 绑定 |
| 1.8 | 测试 | 现有测试 + 新增多 runner 测试 |
| 步骤 | 文件 | 改动 |
|---|---|---|
| 2.1 | 拆分 SettingsPanel | 提取 AppearanceTab, LLMTab, MarketTab, RunnerPanel |
| 2.2 | RunnerPanel.tsx | 新 UI:runner 卡片列表 + 模板 + 切换 |
| 2.3 | API 层 | /api/runner/token → /api/runners 迁移 |
| 步骤 | 文件 | 改动 |
|---|---|---|
| 3.1 | channel/feishu_settings.go | Settings Card 展示多 runner 列表 |
| 3.2 | Card 交互 | 切换活跃 runner 的按钮 |
| 决策 | 选择 | 否决方案 | 理由 |
|---|---|---|---|
| connections key | 不改,加映射层 | 改 key 为 composite | 零迁移风险,向后兼容 |
| Runner 身份识别 | token 查 DB | URL 加 runnerName | 不改 URL 格式,不增加 CLI 参数 |
| 活跃状态持久化 | user_settings 表 | 新建 active_runners 表 | 复用现有机制 |
| DB 迁移 | 新建 runners + 迁移 | ALTER runner_tokens | SQLite ALTER 限制多,新建更干净 |
| 前端术语 | “工作环境” | “Runner” | 用户友好 |
| 切换时忙碌处理 | 警告 + 允许切换 | 阻止切换 | 工具调用层面天然安全 |
| 风险 | 缓解 |
|---|---|
| 旧版 runner 连新版 server | RunnerName 字段可选,fallback 到 DB 查找 |
| 同名 runner 并发连接 | userRunnersEntry.mu 保护,后者踢掉前者 |
| 用户删除在线 runner | API 层检查在线状态,在线时先断开再删 |
| DB 迁移失败 | CREATE TABLE IF NOT EXISTS + INSERT OR IGNORE |