工作区与隔离
什么是工作区?
标题为“什么是工作区?”的部分工作区是 Control Center 中最高层级的隔离租户。它把智能体、仓库、空间、工单、记忆、流水线和成员归入同一个有界上下文。工作区内发生的一切都留在其中。
一个典型的工作区对应一个项目或一个代码库。你可能为 “api-service”、“frontend-v2” 和 “infrastructure” 分别建立工作区。
工作区范围的数据
标题为“工作区范围的数据”的部分工作区的数据行存放在该工作区自己的 SQLite 文件中,路径为 <dataDir>/<workspaceId>/workspace.db。每个工作区得到的是一个目录,而不是一个裸文件——因为工作区积累的不止是一个数据库:每个对话的工作树、智能体与技能文件、聊天机器人凭据——给它一个文件夹,意味着这一切同生共死、一起被删除。
| 实体 | 范围 |
|---|---|
| 智能体 | 恰好属于一个工作区 |
| 仓库 | 注册到一个工作区(见下文) |
| 空间、对话与消息 | 工作区范围 |
| 工单、项目、计划、编排 | 工作区范围 |
| 记忆事实、策略、域、访问授权 | 工作区范围 |
| 流水线运行、模板、触发器 | 工作区范围 |
| Pull request 状态、审查空间、审查队列 | 工作区范围 |
| 智能体运行日志与工作记忆 | 工作区范围 |
| 成员、邀请、按仓库的授予权限 | 工作区范围 |
| 代码图符号与边 | 在工作区文件中按 repoId 限定范围 |
这些数据行大多带有 workspaceId 列;独立的数据库文件强制实施隔离。参见下文的隔离不变式。
仓库属于工作区范围;跨工作区的身份由路径决定
标题为“仓库属于工作区范围;跨工作区的身份由路径决定”的部分仓库数据行存放在其工作区自己的数据库文件中——文件_本身_就是作用范围,因此该表没有 workspaceId 列,也没有关联表。
你在 设置 → 工作区 → 仓库 中把仓库添加到工作区。把同一个检出注册到两个工作区会创建两行彼此独立、各带一个 id 的记录;仓库_跨_工作区的身份由文件系统路径(或 GitHub owner/name)决定,绝不依赖 id。
注册很严格:路径必须位于某个 git 工作树内,必须存在 origin 远程,且该远程必须指向受支持的代码托管(github.com、gitlab.com 或 bitbucket.org)。代码托管从远程读取并存储在仓库上,因此一个工作区可以同时容纳来自这三者的仓库。其它情况一律拒绝。你用来选择的文件夹浏览器列出的是服务器主机的文件系统,且被限制在服务器的 --repo-roots 允许列表内(默认是服务器用户的主目录)。
从工作区中移除仓库会直接删除该行,并级联删除其代码符号、边、文件、索引检查点和按成员的授予权限。磁盘上的检出不受影响。
代码图存放在同一个文件中,以 repoId 为键,因为不同的工作区可能在不同分支上持有同一个仓库,代码也各不相同。
隔离不变式
标题为“隔离不变式”的部分跨工作区数据泄漏被视为缺陷。隔离是结构性的,在多个层面上强制实施:
- 数据库层。 每个工作区都有自己的 SQLite 文件,由
WorkspaceDatabaseManager.of(workspaceId)分发。工作区数据库不声明users、workspaces或任何其它工作区的表,因此跨工作区读取不是你能写出来的缺陷——它根本无法编译。仓库在每次调用时解析自己的 DAO,而不是缓存一个;并且由于每个仓库方法都接收一个必填的workspaceId,工作区永远无法被推断或取默认值。 - 工作区 id 是一个路径段。 由于该 id 会成为一个目录名,它必须匹配安全的单段模式(以字母数字开头,之后是字母数字、点、下划线、连字符;不能有
..)。在产品中,id 处处都是 UUID。其它情况会抛错,而不是被清洗——这就是路径遍历防护,它会大声失败,而不是把工作区的数据写到别人选定的路径上。 - 纵深防御。
workspaceId列依然存在,也依然会被写入。在单个文件内它们是冗余的,但能让同步馈送触发器、FTS 索引和现有行结构保持不变,也让文件在被单独检视时能自我描述——导出与导入正是依赖这一点。 - 领域层。 按 id 变更实体的服务会在继续之前,在一个唯一的收口点校验
entity.workspaceId == workspaceId,不匹配时抛出WorkspaceMismatchException——对于来自错误上下文的 id,这就是那种响亮的失败。大声拒绝很重要:静默的空操作会掩盖缺陷,而继续执行则会造成泄漏。 - RPC 层。 仓库 RPC 分发器就是收口点。每个工作区范围的操作都必须在自己的参数中携带
workspace_id——不存在会泄漏的按会话 “当前工作区”,未知 id 会在任何东西打开数据库_之前_就被当作未找到拒绝,因此一个错误的 id 无法凭空造出一个幽灵workspace.db。真正全局的操作(新闻流、机群队列)会自我声明为不限范围,这是一个显式且经过评审的决定。MCP 工具遵循同样的规则:任何触及工作区范围数据的工具都需要workspace_id。 - 跨工作区扇出是可枚举的。 回答关于多个工作区的问题意味着打开多个数据库。这种扇出被限制在一个辅助类
CrossWorkspaceQueries中,因此所有合法跨越边界的东西完整地列在这一个文件的调用点上,而不是散落在文档注释背后。这些调用点是:服务器范围的聚合、启动对账器、保留与 GC 清扫、备份、事件路由器——以及下文的成员身份查找。 - 仅凭 ID 访问是不够的。 用 UUID 查出一个实体,并不能证明它属于调用者的工作区。文件拆分让错误的文件根本不可达;领域层校验则能抓住经由合法文件传来的错误 id。
授予访问权的是成员身份,而不是配对密钥
标题为“授予访问权的是成员身份,而不是配对密钥”的部分持有设备凭据只认证一台_设备_。它不授权工作区内的任何东西。每个工作区范围的操作都会解析调用者的成员身份角色,并强制实施由操作种类推导出的下限——读取需要 guest,变更需要 member,破坏性操作需要 admin——而暴露仓库内容的操作还会额外检查调用者的按仓库授予权限。一个有效但没有成员身份的配对密钥会得到 Not a member of this workspace。
它对隔离的影响很利落:成员身份行存放在_工作区自己的_文件里,因此 “谁在这个工作区里?” 是一次普通的限定范围读取。反过来的问题——“我属于哪些工作区?”——按定义就跨文件,因此它是少数被认可的 CrossWorkspaceQueries 扇出之一,也是工作区选择器在任何工作区被选中之前所运行的查询。
角色阶梯与仓库授予权限参见多人协作。
全局注册表
标题为“全局注册表”的部分并非所有东西都属于工作区范围。第二个数据库 <dataDir>/global.db 存放真正服务器范围的内容:
- 工作区注册表,让切换器无需打开任何工作区文件就能列出所有工作区——也让工作区的名称、logo、所有者、手动排序和软删除标记存放在它们所描述的文件之外;
- 身份:用户、用户偏好设置和已配对设备——因为一个人在跨所有工作区时都是同一个用户,而一台已配对的设备能在工作区被删除后继续存在;
- 机群队列(工作进程、作业、放置日志),其调度器在每一轮都会把整个队列与所有工作进程进行匹配;
- 新闻流,它按用户划分(而不是按工作区)——你添加的源会跟着你进入每一个工作区;
workspace_routes,这个认证前的索引为那些只带着一个密钥或不透明 id(邀请哈希、webhook 令牌、深度链接)而不带工作区到来的入口回答 “哪个工作区拥有这个密钥?”。在那里未命中就是未找到;这里刻意没有扫描兜底;- 安装级设置与身份:安装 id、主机级设置、SSO 连接和受管操作策略(一个全安装范围的约束,只能收紧工作区护栏)。
启动时只打开这个文件。工作区文件在首次触及时才惰性打开,因此无论各工作区积累了多少历史,启动开销都保持平稳,每个文件的 quick_check 也只在首次使用时付出,而不在通往就绪横幅的路径上付出。
工作区文件只保留一行 workspace_meta 用于自我标识(它的 id、创建它的那次安装、创建时使用的 schema 版本、创建时间)。关于工作区的所有描述性信息都在注册表里。
创建工作区
标题为“创建工作区”的部分创建工作区不只是插入一行注册表记录。服务器在一次操作中把创建者标记为 ownerUserId,记录该用户的所有者角色成员身份,并发布一个 WorkspaceCreated 领域事件。该事件的监听器随后会幂等地、在后台预置:
- 一个 CEO 智能体外加四名专家——
qa、architect、engineer、librarian——这些专家向 CEO 汇报; - 内置流水线模板及其触发器(事件触发器和手动触发器默认启用;除每日的工作树 GC 清扫外,定时触发器需要主动开启);
- 入门评测套件。
这会发生于_每一个_工作区,而不只是第一个。预置的智能体在创建时不带适配器,因此它们运行在 Control Center (built-in) 上,使用 Anthropic 的默认模型(除非你另行指定,否则是 claude-opus-4-8),直到你在 设置 → 工作区 → 智能体 中为每个智能体设置适配器和模型。参见智能体模型。
写入所有者成员身份这件事比看上去更重要:没有它,刚创建的工作区将没有任何成员,创建者接下来发起的每一次工作区范围调用都会被拒绝。
删除、导出与导入
标题为“删除、导出与导入”的部分删除工作区会标记注册表行并解除目录的链接。该工作区会从所有列表和查找中消失。注册表行会保留(软删除),因此 id 仍然为备份和维护所知晓,但 workspace.delete 会立即通过 WorkspaceDatabaseManager.dropAndClose 丢弃该工作区的目录——数据库、工作树、智能体文件及其余内容。只有工作区所有者才能删除它。
由于一个工作区就是一个文件,传递工作区是一次文件操作,而不是逐表转储:
workspace.export就是一次VACUUM INTO——在服务器持续提供服务的同时,于一个读事务下取得的、一致且已整理碎片的快照。workspace.import会接纳这样的文件,替换目标工作区当前持有的内容。内嵌的workspace_meta让导入能够区分 “重新接纳自己的文件” 与 “来自另一次安装的文件”(后者是允许的,但会被记录,而不是不留痕迹)。server.backupNow把整个安装快照到一个带时间戳的目录,其结构与实际的数据目录一致——manifest.json、global.db,以及每个工作区一个<workspaceId>/workspace.db——因此恢复就是把它复制回来。server.listBackups读回该目录,按最新优先,并给出每个快照的大小和工作区列表。清单缺失或引用了不存在文件的快照仍会被报告,并标记为未完成——把它藏起来,正是让运维人员误以为自己有一份实际上并不存在的备份的原因。
备份操作位于 设置 → 服务器 → 备份与恢复 下:立即备份 会生成一份安装快照,列表会显示哪些快照已完成,并允许安装所有者确认后删除其中之一,每个工作区也都提供导出与导入。内置的 Workspace backup 流水线按每周计划只备份该工作区的数据库,并删除自身早于所配置保留期限的备份。从安装快照恢复某个工作区,就是用 workspace.import 指向该快照的 <workspaceId>/workspace.db;恢复整个安装仍然是在停止服务器后把快照目录复制回来。
它们都不携带工作区目录的其余部分。工作区积累的不止是一个数据库——粘贴的图片、技能文件与智能体文件、聊天凭据、工作树——而导出和导入移动的是文件,因此它旁边的一切都留在原处。对凭据来说是刻意为之,对工作树来说无所谓;对其它内容而言,这是一个需要知道的限制,而不是一项特性。备份参考中有完整的表格。
在服务器与你的设备之间移动备份
标题为“在服务器与你的设备之间移动备份”的部分上面的每个操作都以服务器上的路径来表达,只有当服务器就是你自己的机器时,这才算一个完整的答案。三条签名的 HTTP 路由——/backup/workspace、/backup/snapshot 和 /backup/restore——为其它所有拓扑结构承载字节流,走的是媒体代理和图片存储已经在用的那条带认证的通道。在页面上,这表现为每个导出和每个快照旁边的 下载 按钮(落点由你选择),以及用于从你正在使用的机器上的文件恢复的 选择文件并上传。当服务器不是你的机器时,能派上用场的是上传:workspace.import 要求文件已经位于主机上,而这就是它到达主机的方式。
有三点值得了解:
- 下载会新生成一份副本,服务器不留存它。 下载并不是 “取回导出按钮生成的那个文件”——它会重新导出、流式发送,然后删除。响应带
no-store和Accept-Ranges: none,原因相同:续传的范围请求会把两次不同的导出拼接成一个看起来有效、实则无效的文件。 - 上传是流式写入磁盘、绝不缓冲的,两端都是如此,暂存的副本在每一条退出路径上都会被删除——包括被拒绝时。工作区数据库不是一张截图。
- 中继连接没有 HTTP 源,因此那里的传输控件会被禁用,并附上说明原因的提示。一切以服务器端路径来表达的东西在中继上都能继续工作;只有承载字节流的通道需要直连。
多个工作区
标题为“多个工作区”的部分你可以并行运行多个工作区。它们各自独立运作:一个工作区中的智能体无法给另一个工作区中的智能体发消息,无法读取另一个工作区的记忆,也看不到它的工单。智能体之间消息传递的收件人解析是精确的,绝不跨越工作区。
不存在跨工作区的仪表盘。位于 /workspaces/:workspaceId/observability 的分析界面订阅一个有界的近期运行全局馈送,并立即把它收窄到当前活动的工作区,因此你在那里看到的始终是一次一个工作区。人真正会接触到的多工作区界面,只有工作区选择器和切换器。
有一个值得了解的实际限制:有人正在使用的工作区会保持打开——没有 LRU 淘汰。超过 32 个时,服务器会发出警告而不是拒绝,因为每个打开的文件都持有一个后台 isolate 和一个页面缓存。跨工作区扇出会临时打开文件,之后如果没有任何其它地方占用它们,就把它们关闭。