Bỏ qua để đến nội dung

Kiến trúc

Control Center đi theo Clean Architecture hướng tính năng với mẫu cổng và adapter. Cơ sở mã được tổ chức quanh các miền nghiệp vụ (tính năng), với những quy tắc phụ thuộc nghiêm ngặt được thực thi bằng một bài kiểm thử kiến trúc tự động.

Máy khách mỏng trên một máy chủ không giao diện

Phần tiêu đề “Máy khách mỏng trên một máy chủ không giao diện”

Control Center là một kiến trúc máy khách mỏng. Không máy khách nào mở cơ sở dữ liệu — một tiến trình cc_server không giao diện sở hữu dữ liệu (một global.db nhỏ cùng một tệp SQLite cho mỗi không gian làm việc, mở theo nhu cầu) và phục vụ dữ liệu ấy qua RPC WebSocket. Ứng dụng desktop, bản web và ứng dụng đồng hành trên điện thoại đều là những trình hiển thị trên cùng một kết nối RPC đó.

Ứng dụng desktop chọn cách tiếp cận máy chủ của mình trong lần khởi chạy đầu tiên: cục bộ (nó khởi tạo một cc_server trên chính máy này và trao đổi qua loopback — mặc định) hoặc từ xa (nó quay số tới một cc_server ở nơi khác qua WebSocket bảo mật). Máy khách web luôn là từ xa. Bộ phân giải lúc khởi động (bootstrap/server_backend.dart) chọn đường đi trước khi Riverpod tồn tại, rồi ghi đè rpcClientProvider bằng máy khách đã kết nối — nhờ vậy mọi tính năng đọc và ghi qua máy chủ chứ không qua cơ sở dữ liệu cục bộ.

Bản thân tầng RPC là không trạng thái: một phiên được xác thực, nhưng không bị ràng buộc vào một không gian làm việc. Mọi lời gọi theo phạm vi không gian làm việc đều mang workspace_id của riêng nó trong các tham số, và bộ điều phối từ chối lời gọi như thế nếu thiếu tham số ấy. Chính điều đó cho phép hai máy khách trên một máy chủ nắm giữ những không gian làm việc đang hoạt động khác nhau mà không có một “không gian làm việc hiện tại” do máy chủ giữ để chúng mâu thuẫn với nhau — và nghĩa là việc cô lập được thực thi theo từng lời gọi, chứ không theo từng kết nối.

Đây là một không gian làm việc Dart dùng chung một lockfile đã giải, gồm hai mươi mốt thành viên — mười lăm gói cộng sáu ứng dụng — với ứng dụng desktop+web (control_center) làm gốc của không gian làm việc. Sáu ứng dụng là binary máy chủ không giao diện (cc_server), bộ thực thi đội máy không giao diện (cc_worker), PWA điện thoại (cc_remote), broker chuyển tiếp WebSocket (cc_signaling_server), phòng trưng bày Widgetbook (cc_gallery) và máy chủ demo công khai (cc_demo_server). Mười lăm gói là cc_ui (hệ thống thiết kế), cc_domain (kernel dùng chung thuần Dart: thực thể, đối tượng giá trị, cổng, sự kiện và tầng miền của mọi tính năng), cc_harness (kernel agent tích hợp: một vòng lặp agent thuần Dart — tin nhắn, cổng nhà cung cấp, công cụ, nén, điều hướng, hook, subagent — không dùng dart:io và không phụ thuộc cc_* nào khác), cc_harness_runtime (adapter runtime harness tích hợp: nhà cung cấp truyền phát chỉ chạy trên VM, môi giới thông tin xác thực OAuth/PKCE, kho thông tin xác thực và bộ công cụ chung — chạy agent mà không cần cài CLI bên ngoài), cc_rpc (transport máy khách), cc_host (kernel máy chủ), cc_data (repository từ xa), cc_persistence (DB phía máy chủ), cc_server_core (tổ hợp ứng dụng–máy chủ + runtime danh tính/hiện diện/đội máy/đánh giá), cc_infra (adapter chỉ chạy trên VM), cc_mcp (công cụ MCP), cc_mcp_client (máy khách cho các máy chủ MCP bên ngoài, cầu nối công cụ của chúng vào sổ đăng ký cục bộ), cc_markdown (engine markdown trong kho), cc_natives (nút lá FFI) và system_audio_capture. Xem Triển khai và máy khách để biết ma trận máy khách đầy đủ và thứ gì chạy ở đâu.

Presentation → Application/Providers → Domain ← Infrastructure
  • Tầng miền: các thực thể, đối tượng giá trị, giao diện repository, cổng và dịch vụ miền thuần Dart. Không import hạ tầng nào (không dio, drift hay mô hình mạng).
  • Tầng trình bày: màn hình, widget, notifier. Không truy cập trực tiếp drift/DAO/tầng dữ liệu; mọi thứ đều đi qua provider Riverpod → repository.
  • Tầng hạ tầng: những triển khai cụ thể của cổng miền và giao diện repository. Phụ thuộc vào miền, chứ không bao giờ ngược lại.

Cấu trúc đầy đủ của một tính năng là:

feature_name/
├── data/ # Repository implementations, data sources, services, DTOs, mappers
├── domain/ # Entities, repository interfaces, ports, use cases
├── presentation/ # Screens, widgets, notifiers
└── providers/ # Riverpod providers

Rất ít tính năng dưới lib/features/ thực sự có đủ cả bốn thư mục, và những khoảng trống ấy mang thông tin chứ không phải ngẫu nhiên. Kernel dùng chung đã thu mọi domain/ của các tính năng vào cc_domain, còn máy khách mỏng không giữ triển khai repository nào, nên hầu hết tính năng dưới lib/ chỉ có presentation/ cộng providers/ và không gì khác. Một thư mục data/ hoặc domain/ còn sót lại trong máy khách đánh dấu thứ gì đó thực sự thuộc phía máy khách.

Có hai ngoại lệ được nêu tên đáng để biết:

  • mcp chỉ có providers. Giao diện cài đặt và trạng thái của nó nằm dưới settings/, còn bề mặt công cụ tự nó là một gói (cc_mcp), không phải một thư mục tính năng.
  • orchestration và plan_studio chỉ có presentation/ và providers/.

Tính năng guardrails đặt tầng miền của nó trong cc_domain/features/guardrails/ thay vì ứng dụng gốc, bởi máy chủ mới là nơi thực thi nó: một quy tắc ánh xạ một ActionClass (hoặc một commandPrefix của shell) thành một quyết định — allow, prompt hoặc deny — tại một phạm vi; các quy tắc được lưu theo từng không gian làm việc và mọi hành động có kiểm soát của agent đều được phân giải qua chuỗi chính sách trước khi chạy. Việc phân giải đi theo space > agent > workspace > mode preset > built-in default và phạm vi đầu tiên có quy tắc khớp sẽ quyết định; mức hạn chế nhất chỉ có vai trò phân xử khi hòa trong cùng một phạm vi và khi kết hợp nhiều lớp mà một hành động khai báo. Bề mặt dành cho người vận hành là Cài đặt → Không gian làm việc → Quyền của agent, tại /workspaces/<id>/settings/workspace/permissions. Xem Rào chắn để biết mô hình.

packages/cc_domain/lib/core/domain/ chứa các thực thể và repository được chia sẻ giữa 3+ tính năng. Nó thuần Dart và không có phụ thuộc hạ tầng nào, nhờ đó cả máy khách Flutter lẫn binary máy chủ không dùng Flutter đều import được cùng những định nghĩa ấy:

  • Thực thể lõi: Agent, AgentRunLog, Workspace, Repo, ReviewSpaceAssociation
  • Danh tính và đa người dùng: User, Principal (sealed UserPrincipal | AgentPrincipal), WorkspaceMember, WorkspaceRole
  • Thực thể bộ nhớ: MemoryFact, MemoryPolicy, AgentWorkingMemory, MemoryAccessGrant
  • Đối tượng giá trị dùng chung: AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • Cổng dùng chung: SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • Dịch vụ miền: MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + các loại sự kiện (không gian làm việc/agent, PR/đánh giá, nhắn tin, phiếu, quy trình, điều phối, bộ nhớ, lịch/cuộc họp, danh tính và tư cách thành viên, khả năng quan sát)

Control Center là hệ thống đa người dùng: con người và agent là những tác nhân bình đẳng, được hợp nhất qua một Principal. Một thành viên không gian làm việc là một User được gắn với một Workspace ở một WorkspaceRole (owner/admin/member/viewer/guest). Tư cách thành viên chính là ranh giới truy cập — không phải một khóa ghép nối — và các quyền cấp theo từng kho mã ngăn không cho tư cách thành viên không gian làm việc tạo ra đặc quyền cao hơn máy chủ mã. Người dùng đầu tiên được tạo trên một máy chủ mới sở hữu bản cài đặt; những người khác tham gia qua lời mời hoặc OIDC. Việc tạo một không gian làm việc đánh dấu người dùng đó là owner.

Cộng tác thời gian thực dùng máy chủ có thẩm quyền + last-writer-wins (LWW) theo từng trường, chứ không dùng CRDT. Hiện diện (ai đang ở đây, họ ở đâu, con trỏ, đang gõ, trạng thái trực tiếp và chi phí đang chạy của một agent) là một làn tạm thời không bao giờ được lưu lại; trạng thái bền vững chạy trên một xương sống gồm thay đổi lạc quan + tái cơ sở từ máy chủ + LWW. Con người và agent cùng chung một danh sách thành viên. Khi chỉ có một người vận hành là con người, làn hiện diện nằm im và không có phần giao diện danh sách nào xuất hiện — tính năng đa người dùng không khiến người dùng đơn lẻ phải trả giá gì.

Cổng là những giao diện trừu tượng trong tầng miền. Adapter là những triển khai cụ thể trong tầng hạ tầng. Vì máy chủ không giao diện sở hữu dữ liệu và phải build được mà không cần Flutter, gần như mọi adapter chỉ chạy trên VM và nằm trong packages/cc_infra/lib/src/, được tổ chức theo mối quan tâm:

Cổng Vị trí adapter
SandboxPort cc_infra/lib/src/sandboxing/ (native_sandbox_adapter.dart)
CredentialBrokerPort cc_infra/lib/src/sandboxing/ (env_credential_broker.dart, github_fine_grained_broker.dart)
GitRepoInspectorPort cc_infra/lib/src/git/ (git_repo_inspector.dart)
EmbeddingPort cc_infra/lib/src/embedding/ (embedding_service.dart)
NotificationPort lib/core/notifications/ (phía máy khách: hiển thị sự kiện từ máy chủ ngay tại chỗ)
AgentBackend cc_infra/lib/src/dispatch/backends/ (acp_backend.dart, harness_backend.dart, cli_backends.dart)
TicketProviderPort cc_infra/lib/src/tickets/linear/ (LinearTicketAdapter). Jira, ClickUp và GitHub Issues là những adapter đồng bộ phiếu trong cc_infra/lib/src/tickets/sync/; phiếu cục bộ nằm trong cơ sở dữ liệu của không gian làm việc và không dùng TicketProviderPort.
PipelineEnginePort cc_domain features/pipelines/domain/services/ (một dịch vụ miền thuần Dart — nó không cần hạ tầng nào)

Gốc tổ hợp của ứng dụng (di/providers.dart) gắn các cổng phía máy khách với những triển khai thông qua provider Riverpod; máy chủ tổ hợp các adapter của mình trong cc_server_core.

Riverpod cho toàn bộ trạng thái phía máy khách:

  • Notifier<T> và AsyncNotifier<T> cho trạng thái có thể thay đổi
  • FutureProvider<T> cho dữ liệu bất đồng bộ
  • Danh sách và các hàng trực tiếp đến qua đăng ký RPC dưới dạng AsyncValue<List<T>> — máy chủ dùng Drift .watch() ở bên trong; không máy khách nào mở Drift
  • Các công cụ MCP nhận phụ thuộc dưới dạng tham số khởi tạo có kiểu, không bao giờ qua Ref

Drift (SQLite), được chia thành hai cơ sở dữ liệu, cả hai đều nằm trong cc_persistence:

  • GlobalDatabase (<dataDir>/global.db) — một danh sách ngắn những bảng thực sự thuộc phạm vi toàn máy chủ: sổ đăng ký không gian làm việc, người dùng/tùy chọn/thiết bị đã ghép nối, kết nối SSO, cài đặt máy chủ toàn bản cài, chính sách hành động được quản lý (một giới hạn toàn bản cài chỉ có thể siết chặt thêm rào chắn của không gian làm việc), bảng tin của từng người dùng, hàng đợi đội máy (worker/job/nhật ký xếp chỗ), chỉ mục workspace_routes dùng trước khi xác thực và siêu dữ liệu bản cài. Lúc khởi động chỉ tệp này được mở, nên chi phí khởi động giữ nguyên không đổi dù các không gian làm việc tích lũy bao nhiêu lịch sử. Thêm một bảng vào đây là một quyết định về cô lập mà một bài kiểm thử kiểu bánh cóc buộc bạn phải biện luận cho nó.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — một thư mục cho mỗi không gian làm việc, chứa một tệp SQLite cộng mọi thứ khác chỉ thuộc về không gian làm việc đó, nên worktree và tệp agent của một cuộc hội thoại nằm cạnh cơ sở dữ liệu và bị xóa cùng với nó. Gần như mọi thứ đều sống ở đây: agent, space, phiếu, bộ nhớ, quy trình, cuộc họp, đồ thị mã, đánh giá, kho mã. Các tệp được mở theo nhu cầu ngay lần chạm đầu tiên thông qua WorkspaceDatabaseManager.of(workspaceId) và khi đó tự trả chi phí quick_check, cài đặt FTS/trigger và vector_init của riêng mình.

Cả hai nửa đều bắt đầu từ một đường cơ sở v1 đã được gộp gọn — onCreate dựng nên lược đồ hiện tại — với các bước migration chỉ được nối thêm khi một cơ sở dữ liệu đã triển khai cần được mang tiếp.

Vì một không gian làm việc là một tệp, việc xuất một không gian làm việc chỉ là một câu lệnh VACUUM INTO duy nhất (workspace.export), chứ không phải kết xuất từng bảng; workspace.import nhận một tệp như vậy và dùng workspace_meta nhúng sẵn để phân biệt tệp của chính nó được nhận lại với một tệp đến từ bản cài khác.

Cả hai cơ sở dữ liệu đều thuộc sở hữu của cc_persistence và chỉ cc_server mở chúng; không máy khách nào mở DB — mọi máy khách đọc/ghi qua RPC. Các bảng được định nghĩa trong packages/cc_persistence/lib/database/tables/, DAO trong .../daos/; thực thể miền là Dart thuần, tách biệt khỏi các lớp bảng Drift, với phần ánh xạ nằm trong các tầng dữ liệu. FTS5 xử lý tìm kiếm toàn văn và sqlite_vector lo phần embeddings, cả hai đều chỉ chạy phía máy chủ; cả hai cơ sở dữ liệu đều chạy PRAGMA foreign_keys=ON ở chế độ journal WAL.

go_router, với một sự thật chi phối tất cả phần còn lại: mọi đích đến trong ứng dụng đều có tiền tố không gian làm việc dưới dạng /workspaces/:workspaceId/…. Id không gian làm việc trong URL là nguồn chân lý duy nhất cho không gian làm việc đang hoạt động — activeWorkspaceIdProvider được điều khiển từ tuyến, chứ không phải theo chiều ngược lại — đó là lý do vì sao việc đọc dữ liệu theo phạm vi không gian làm việc bên ngoài một tuyến có tiền tố không có chỗ nào để lấy id của nó.

  • Các hàm dựng tuyến trong router/routes.dart, mỗi hàm nhận id không gian làm việc làm tham số đầu tiên, chứ không dùng hằng chuỗi
  • Bốn tuyến nằm ngoài ngữ cảnh không gian làm việc nên không mang tiền tố: /splash, /onboarding, /signed-out và /workspaces (trình chọn)
  • ShellRoute bao quanh vỏ ứng dụng; bốn bề mặt đó hiển thị toàn màn hình bên ngoài nó
  • Chốt chặn xác thực: phần thiết lập chưa hoàn tất sẽ chuyển đến /onboarding; phần thiết lập đã hoàn tất nhưng thông tin xác thực máy chủ mã đã mất sẽ chuyển đến /signed-out. Hoàn tất nghĩa là có ít nhất một máy chủ mã được kết nối cho người dùng đã đăng nhập và có ít nhất một không gian làm việc tồn tại — không nhất thiết là GitHub, và CLI gh không phải một phương thức xác thực
  • Cổng onboarding nằm trong features/auth/providers/

Toàn bộ HTTP ra bên ngoài đều nằm ở máy chủ. Máy khách không bao giờ gọi trực tiếp GitHub, Linear hay Google — chúng gọi các thao tác RPC của máy chủ, và ngay cả media từ xa cũng được lấy qua endpoint /proxy/media của máy chủ thay vì lấy trực tiếp từ nguồn gốc.

  • Các máy khách dio trong packages/cc_infra/lib/src/: GitHubApiClient, GitHubPrClient, GitHubContentClient, GitHubGraphQLClient, GitLabApiClient, BitbucketApiClient, LinearGraphQlClient và GoogleCalendarApiClient
  • Việc tiêm token xác thực qua các interceptor, bao gồm một interceptor Google OAuth theo từng tài khoản trong cc_server_core để làm mới token khi gặp 401
  • Mọi lỗi đều được ánh xạ qua packages/cc_infra/lib/src/network/error_mapper.dart → các lớp con AppException được định kiểu, định nghĩa trong cc_domain

Máy chủ nắm giữ những thông tin xác thực thực sự quan trọng: token GitHub và token hệ thống phiếu dùng cho việc khởi chạy agent, khóa nhà cung cấp LLM và token OAuth đều nằm dưới thư mục dữ liệu của máy chủ hoặc trong môi trường của nó. Thứ mà keychain của máy khách vẫn giữ là thông tin xác thực thiết bị của chính nó và khóa ghép nối cho một máy chủ từ xa.

  • Bí mật nằm trong flutter_secure_storage (keychain macOS, trình quản lý thông tin xác thực Windows, libsecret trên Linux) — đó là thông tin xác thực thiết bị và khóa ghép nối từ xa của máy khách, không phải token của nhà cung cấp
  • shared_preferences chỉ dành cho các tùy chọn không nhạy cảm (giao diện, phông chữ, bố cục)
  • Những bí mật nhà cung cấp còn sót lại từ các bản cài cũ được chuyển giao một lần cho máy chủ bởi features/auth/providers/credential_migration.dart, rồi bị xóa khỏi keychain của máy khách

Các ràng buộc kiến trúc được kiểm chứng bởi test/core/architecture_constraints_test.dart; bài kiểm thử này thất bại nếu các quy tắc phụ thuộc bị vi phạm.