跳到內容

工作區與隔離

工作區是 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 存放的是真正屬於整個伺服器的內容:

  • 工作區註冊表,讓切換器不必開啟任何一個工作區檔案就能列出所有工作區——也讓工作區的名稱、標誌、擁有者、手動排序與軟刪除標記存放在它們所描述的那個檔案之外;
  • 身分:使用者、使用者偏好設定與已配對裝置,因為一個人在所有工作區裡都是同一個使用者,而一台已配對的裝置能在工作區被刪除後繼續存在;
  • 機群佇列(工作程序、作業、放置記錄),其排程器在每一輪都會把整條佇列與每個工作程序配對;
  • 新聞動態,它以使用者為單位(而非以工作區為單位)——你加入的動態來源會跟著你進入每一個工作區;
  • 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 會把該目錄讀回來,最新的在前,並附上每個快照的大小與工作區清單。manifest 遺失、或指名了不存在檔案的快照仍會回報,並標記為不完整——把它藏起來,正是讓操作者誤以為自己有一份實際上並不存在的備份的途徑。

備份操作位於 設定 → 伺服器 → 備份與還原 之下:立即備份 會取得一份安裝快照,清單會顯示哪些快照是完整的,並讓安裝擁有者在確認後刪除其中一份,而每個工作區也都提供匯出與匯入。內建的 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 與一個頁面快取。跨工作區扇出會短暫開啟檔案,之後若沒有其他東西取用它們,就會把它們關閉。