콘텐츠로 이동

워크스페이스와 격리

워크스페이스는 Control Center의 최상위 격리 테넌트입니다. 에이전트, 리포지토리, 스페이스, 티켓, 메모리, 파이프라인, 멤버를 하나의 바운디드 컨텍스트로 묶습니다. 워크스페이스 안에서 일어나는 모든 일은 그 안에 머뭅니다.

일반적인 워크스페이스는 프로젝트 하나 또는 코드베이스 하나에 대응합니다. “api-service”, “frontend-v2”, “infrastructure”를 위한 워크스페이스를 따로 둘 수 있습니다.

워크스페이스의 행들은 그 워크스페이스 전용 SQLite 파일, <dataDir>/<workspaceId>/workspace.db에 있습니다. 각 워크스페이스가 파일 하나가 아니라 디렉터리를 받는 이유는 워크스페이스가 데이터베이스보다 많은 것을 쌓기 때문입니다 — 대화별 워크트리, 에이전트 및 스킬 파일, 챗봇 자격 증명 — 그리고 폴더를 주면 그 모든 것이 함께 살고 함께 삭제됩니다.

엔터티 범위
에이전트 정확히 하나의 워크스페이스에 속함
리포지토리 하나의 워크스페이스에 등록됨(아래 참고)
스페이스, 대화, 메시지 워크스페이스 범위
티켓, 프로젝트, 플랜, 오케스트레이션 워크스페이스 범위
메모리 팩트, 정책, 도메인, 접근 권한 부여 워크스페이스 범위
파이프라인 실행, 템플릿, 트리거 워크스페이스 범위
풀 리퀘스트 상태, 리뷰 스페이스, 리뷰 코호트 워크스페이스 범위
에이전트 실행 로그와 워킹 메모리 워크스페이스 범위
멤버, 초대, 리포지토리별 권한 부여 워크스페이스 범위
코드 그래프 심볼과 엣지 워크스페이스 파일 안에서 repoId로 범위 지정

이 행들 대부분은 workspaceId 컬럼을 가집니다. 별도의 데이터베이스 파일이 격리를 강제합니다. 아래의 격리 불변 조건을 참고하세요.

리포지토리는 워크스페이스 범위이고, 워크스페이스 간 식별은 경로로 정해집니다

섹션 제목: “리포지토리는 워크스페이스 범위이고, 워크스페이스 간 식별은 경로로 정해집니다”

리포지토리 행은 그 워크스페이스 전용 데이터베이스 파일에 있습니다 — 파일이 곧 범위이므로, 이 테이블에는 workspaceId 컬럼도 조인 테이블도 없습니다.

리포지토리 추가는 설정 → 워크스페이스 → 리포지토리에서 합니다. 같은 체크아웃을 두 워크스페이스에 등록하면 id가 각각 다른 독립적인 행 두 개가 만들어집니다. 워크스페이스를 넘나드는 리포지토리 식별은 파일 시스템 경로(또는 GitHub 소유자/이름)로 정해지며, 결코 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(초대 해시, 웹훅 토큰, 딥 링크)만 들고 오고 워크스페이스는 없는 진입점에 대해 “이 키는 어느 워크스페이스의 것인가?“에 답하는 인증 전 인덱스입니다. 여기서 못 찾으면 찾을 수 없음이며, 의도적으로 스캔 폴백이 없습니다;
  • 설치 전체 설정과 신원: 설치 id, 호스트 수준 설정, SSO 연결, 관리형 동작 정책(워크스페이스 가드레일을 조일 수만 있는 설치 전체 제한)입니다.

부팅 시에는 이 파일만 엽니다. 워크스페이스 파일은 처음 접근할 때 지연해서 열리므로, 워크스페이스가 아무리 많은 기록을 쌓아도 시작 비용은 일정하게 유지되고, 파일별 quick_check는 준비 완료 배너로 가는 경로가 아니라 첫 사용 시점에 치릅니다.

워크스페이스 파일은 자기 식별용으로 단일 행 workspace_meta만 담습니다(자신의 id, 이를 만든 설치, 만들어질 당시의 스키마 버전, 시점). 워크스페이스를 설명하는 모든 정보는 레지스트리에 있습니다.

워크스페이스를 만드는 것은 레지스트리 행을 넣는 것보다 많은 일을 합니다. 한 번의 연산으로 서버는 만든 사용자를 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개를 넘으면 서버는 거부하지 않고 경고합니다. 열린 파일마다 백그라운드 아이솔레이트와 페이지 캐시를 들고 있기 때문입니다. 워크스페이스 간 팬아웃은 파일을 잠깐 열고, 다른 곳에서 그 파일을 차지하지 않았다면 나중에 닫습니다.