Przejdź do głównej zawartości

Architektura

Control Center stosuje Clean Architecture w podejściu feature-first ze wzorcem portów i adapterów. Kod jest zorganizowany wokół domen biznesowych (funkcji), a ścisłe reguły zależności wymusza zautomatyzowany test architektury.

Control Center to architektura cienkiego klienta. Żaden klient nie otwiera bazy danych — dane należą do procesu headless cc_server (mały plik global.db plus po jednym pliku SQLite na obszar roboczy, otwieranym leniwie), który udostępnia je przez RPC po WebSocket. Aplikacja desktopowa, wersja webowa i towarzysz na telefonie to wyłącznie renderery działające na tym jednym połączeniu RPC.

Aplikacja desktopowa przy pierwszym uruchomieniu wybiera, jak łączy się ze swoim serwerem: lokalnie (uruchamia cc_server na tej maszynie i rozmawia przez interfejs loopback — domyślnie) albo zdalnie (łączy się z cc_server gdzie indziej przez bezpieczny WebSocket). Klient webowy jest zawsze zdalny. Resolver startowy (bootstrap/server_backend.dart) wybiera ścieżkę, zanim istnieje Riverpod, a następnie nadpisuje rpcClientProvider podłączonym klientem — dzięki temu każda funkcja czyta i zapisuje przez serwer, a nie przez lokalną bazę danych.

Sama warstwa RPC jest bezstanowa: sesja jest uwierzytelniona, ale nie jest powiązana z obszarem roboczym. Każde wywołanie w zakresie obszaru roboczego niesie własny workspace_id w argumentach, a dyspozytor odrzuca takie wywołanie bez niego. Właśnie dzięki temu dwa klienty na jednym serwerze mogą mieć różne aktywne obszary robocze, bez trzymanego po stronie serwera „bieżącego obszaru roboczego”, o który mogłyby się spierać — a izolacja jest wymuszana przy każdym wywołaniu, nie przy połączeniu.

To pojedynczy Dart workspace współdzielący jeden rozwiązany plik lockfile, liczący dwadzieścia jeden członów — piętnaście pakietów plus sześć aplikacji — z aplikacją desktopową i webową (control_center) jako korzeniem workspace. Sześć aplikacji to binarka serwera headless (cc_server), headless wykonawca floty (cc_worker), PWA na telefon (cc_remote), broker przekaźnika WebSocket (cc_signaling_server), galeria Widgetbook (cc_gallery) oraz publiczny host demo (cc_demo_server). Piętnaście pakietów to cc_ui (system projektowy), cc_domain (współdzielony kernel w czystym Darcie: encje, obiekty wartości, porty, zdarzenia i warstwa domenowa każdej funkcji), cc_harness (wbudowany kernel agenta: pętla agenta w czystym Darcie — wiadomości, port dostawcy, narzędzia, kompaktowanie, sterowanie, hooki, subagenci — bez dart:io i bez żadnej innej zależności cc_*), cc_harness_runtime (wbudowany adapter środowiska wykonawczego: dostawcy strumieniowania działający tylko na VM, broker poświadczeń OAuth/PKCE, magazyny poświadczeń i ogólny zestaw narzędzi — uruchamia agentów bez zainstalowanego zewnętrznego CLI), cc_rpc (transporty klienta), cc_host (kernel serwera), cc_data (zdalne repozytoria), cc_persistence (baza danych po stronie serwera), cc_server_core (kompozycja serwera aplikacji + środowisko wykonawcze tożsamości/obecności/floty/ewaluacji), cc_infra (adaptery działające tylko na VM), cc_mcp (narzędzia MCP), cc_mcp_client (klient zewnętrznych serwerów MCP, włączający ich narzędzia do lokalnego rejestru), cc_markdown (silnik markdown wbudowany w repozytorium), cc_natives (liść FFI) oraz system_audio_capture. Pełną matrycę klientów i to, co działa gdzie, znajdziesz w Wdrażanie i klienci.

Presentation → Application/Providers → Domain ← Infrastructure
  • Warstwa domenowa: encje, obiekty wartości, interfejsy repozytoriów, porty i usługi domenowe w czystym Darcie. Zero importów infrastruktury (żadnego dio, drift ani modeli sieciowych).
  • Warstwa prezentacji: ekrany, widżety, notifiery. Brak bezpośredniego dostępu do drift/DAO/warstwy danych; wszystko przechodzi przez providery Riverpod → repozytoria.
  • Warstwa infrastruktury: konkretne implementacje portów domenowych i interfejsów repozytoriów. Zależy od domeny, nigdy odwrotnie.

Pełny kształt funkcji to:

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

Niewiele funkcji w lib/features/ faktycznie ma wszystkie cztery katalogi, a te luki są informacyjne, nie przypadkowe. Współdzielony kernel wchłonął domain/ każdej funkcji do cc_domain, a cienki klient nie zawiera żadnych implementacji repozytoriów, więc większość funkcji w lib/ to wyłącznie presentation/ plus providers/ i nic więcej. Katalog data/ albo domain/, który przetrwał w kliencie, oznacza coś rzeczywiście działającego po stronie klienta.

Warto znać dwa nazwane wyjątki:

  • mcp to tylko providery. Jego interfejs ustawień i statusu mieszka w settings/, a same narzędzia są pakietem (cc_mcp), nie katalogiem funkcji.
  • orchestration i plan_studio mają tylko presentation/ i providers/.

Funkcja guardrails umieszcza swoją warstwę domenową w cc_domain/features/guardrails/, a nie w głównej aplikacji, ponieważ wymusza ją serwer: reguła mapuje ActionClass (albo powłokowy commandPrefix) na decyzję — allow, prompt lub deny — w danym zasięgu, reguły są przechowywane dla każdego obszaru roboczego, a każde objęte nimi działanie agenta przechodzi przez łańcuch polityk, zanim zostanie wykonane. Rozstrzyganie idzie po space > agent > workspace > mode preset > built-in default, a decyduje pierwszy zasięg z pasującą regułą; najbardziej restrykcyjna reguła jest jedynie rozstrzygnięciem przy remisie w obrębie jednego zasięgu oraz przy łączeniu kilku klas, które deklaruje jedno działanie. Powierzchnią dla operatora są Ustawienia → Obszar roboczy → Uprawnienia agentów, pod /workspaces/<id>/settings/workspace/permissions. Model opisują Guardraile.

packages/cc_domain/lib/core/domain/ zawiera encje i repozytoria współdzielone przez 3 lub więcej funkcji. To czysty Dart bez żadnych zależności infrastrukturalnych, dzięki czemu zarówno klient Flutter, jak i niezależna od Fluttera binarka serwera mogą importować te same definicje:

  • Encje podstawowe: Agent, AgentRunLog, Workspace, Repo, ReviewSpaceAssociation
  • Tożsamość i wieloosobowość: User, Principal (sealed UserPrincipal | AgentPrincipal), WorkspaceMember, WorkspaceRole
  • Encje pamięci: MemoryFact, MemoryPolicy, AgentWorkingMemory, MemoryAccessGrant
  • Współdzielone obiekty wartości: AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • Współdzielone porty: SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • Usługi domenowe: MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + typy zdarzeń (obszar roboczy/agent, PR/recenzja, wiadomości, zgłoszenia, potok, orkiestracja, pamięć, kalendarz/spotkania, tożsamość i członkostwo, obserwowalność)

Control Center jest wieloużytkownikowy: ludzie i agenci to równorzędni aktorzy zjednoczeni przez Principal. Członek obszaru roboczego to User powiązany z Workspace na poziomie WorkspaceRole (owner/admin/member/viewer/guest). Członkostwo jest granicą dostępu — nie klucz parowania — a uprawnienia na poziomie repozytoriów nie pozwalają, by członkostwo w obszarze roboczym dawało więcej uprawnień niż sam host kodu. Pierwszy użytkownik utworzony na świeżym serwerze jest właścicielem instalacji; pozostali dołączają przez zaproszenie lub OIDC. Utworzenie obszaru roboczego oznacza tego użytkownika jako owner.

Współpraca w czasie rzeczywistym opiera się na serwerze autorytatywnym i regule last-writer-wins (LWW) na poziomie poszczególnych pól, a nie na CRDT. Obecność (kto tu jest, gdzie się znajduje, kursory, pisanie, bieżący status agenta i koszt uruchomienia) to osobny ulotny kanał, który nigdy nie jest utrwalany; stan trwały jedzie po szkielecie optymistycznych mutacji + przebudowy po stronie serwera + LWW. Ludzie i agenci dzielą jeden skład. Przy jednym operatorze-człowieku kanał obecności pozostaje bezczynny i nie pojawiają się żadne elementy interfejsu związane ze składem — wieloosobowość nic nie kosztuje użytkownika pracującego solo.

Porty to abstrakcyjne interfejsy w warstwie domenowej. Adaptery to konkretne implementacje w infrastrukturze. Ponieważ serwer headless jest właścicielem danych i musi budować się bez Fluttera, niemal każdy adapter działa tylko na VM i mieszka w packages/cc_infra/lib/src/, uporządkowany według zagadnień:

Port Lokalizacja adaptera
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/ (po stronie klienta: renderuje zdarzenia z serwera lokalnie)
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 i GitHub Issues to adaptery synchronizacji zgłoszeń w cc_infra/lib/src/tickets/sync/; lokalne zgłoszenia mieszkają w bazie danych obszaru roboczego bez TicketProviderPort.
PipelineEnginePort cc_domain features/pipelines/domain/services/ (usługa domenowa w czystym Darcie — nie potrzebuje infrastruktury)

Korzeń kompozycji aplikacji (di/providers.dart) wiąże porty po stronie klienta z implementacjami przez providery Riverpod; serwer komponuje swoje adaptery w cc_server_core.

Riverpod dla całego stanu klienta:

  • Notifier<T> i AsyncNotifier<T> do stanu zmiennego
  • FutureProvider<T> do danych asynchronicznych
  • Listy i wiersze na żywo przychodzą przez subskrypcje RPC jako AsyncValue<List<T>> — serwer wewnętrznie używa Drift .watch(); żaden klient nie otwiera Drift
  • Narzędzia MCP otrzymują zależności jako typowane parametry konstruktora, nigdy przez Ref

Drift (SQLite), podzielony na dwie bazy danych, obie w cc_persistence:

  • GlobalDatabase (<dataDir>/global.db) — krótka lista tabel naprawdę wspólnych dla całego serwera: rejestr obszarów roboczych, użytkownicy/preferencje/sparowane urządzenia, połączenia SSO, ustawienia serwera dla całej instalacji, zarządzane polityki działań (ograniczenie dla całej instalacji, które może tylko zaostrzać guardraile obszarów roboczych), newsfeed dla każdego użytkownika, kolejka floty (log workerów/zadań/przydziałów), indeks workspace_routes używany przed uwierzytelnieniem oraz metadane instalacji. Rozruch otwiera tylko ten plik, więc koszt startu pozostaje stały niezależnie od tego, ile historii zgromadzą obszary robocze. Dodanie tutaj tabeli to decyzja o izolacji, którą test zapadkowy zmusza cię uzasadnić.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — jeden katalog na obszar roboczy, zawierający jeden plik SQLite oraz wszystko inne, co należy wyłącznie do tego obszaru roboczego, dzięki czemu worktree rozmowy i pliki agentów leżą obok bazy danych i są usuwane razem z nią. Mieszka tu niemal wszystko: agenci, przestrzenie, zgłoszenia, pamięć, potoki, spotkania, graf kodu, recenzje, repozytoria. Pliki otwierają się leniwie przy pierwszym użyciu przez WorkspaceDatabaseManager.of(workspaceId) i dopiero wtedy płacą za własny quick_check, instalację FTS/wyzwalaczy oraz vector_init.

Obie połowy zaczęły od spłaszczonej bazy wyjściowej v1 — onCreate buduje bieżący schemat — a kroki migracji dopisuje się tylko wtedy, gdy wdrożoną bazę danych trzeba przenieść dalej.

Ponieważ jeden obszar roboczy to jeden plik, wyeksportowanie obszaru roboczego to pojedyncze polecenie VACUUM INTO (workspace.export), a nie zrzut tabela po tabeli; workspace.import przyjmuje taki plik i korzysta z osadzonego workspace_meta, aby odróżnić ponowne przyjęcie własnego pliku od pliku pochodzącego z innej instalacji.

Obie bazy danych należą do cc_persistence i otwiera je wyłącznie cc_server; żaden klient nie otwiera bazy danych — każdy klient czyta i zapisuje przez RPC. Tabele są zdefiniowane w packages/cc_persistence/lib/database/tables/, DAO w .../daos/; encje domenowe to czysty Dart, oddzielony od klas tabel Drift, z mapowaniem w warstwach danych. FTS5 obsługuje wyszukiwanie pełnotekstowe, a sqlite_vector osadzenia — oba tylko po stronie serwera; obie bazy działają z PRAGMA foreign_keys=ON w trybie dziennika WAL.

go_router, z jednym faktem, który rządzi resztą: każde miejsce docelowe w aplikacji ma prefiks obszaru roboczego w postaci /workspaces/:workspaceId/…. Identyfikator obszaru roboczego w adresie URL jest jedynym źródłem prawdy o aktywnym obszarze roboczym — activeWorkspaceIdProvider jest sterowany trasą, a nie odwrotnie — dlatego odczyt danych w zakresie obszaru roboczego poza trasą z prefiksem nie ma skąd wziąć swojego identyfikatora.

  • Funkcje budujące trasy w router/routes.dart, każda przyjmuje identyfikator obszaru roboczego jako pierwszy argument, a nie stałe tekstowe
  • Cztery trasy sprzed kontekstu nie mają prefiksu: /splash, /onboarding, /signed-out i /workspaces (wybór obszaru roboczego)
  • ShellRoute opakowuje powłokę aplikacji; te cztery widoki renderują się na pełnym ekranie poza nią
  • Strażnik uwierzytelniania: nieukończona konfiguracja trafia do /onboarding; ukończona konfiguracja, której poświadczenie hosta kodu zniknęło, trafia do /signed-out. Ukończona oznacza, że dla zalogowanego użytkownika podłączono co najmniej jeden host kodu i istnieje co najmniej jeden obszar roboczy — niekoniecznie GitHub, a CLI gh nie jest metodą uwierzytelniania
  • Brama onboardingu w features/auth/providers/

Cały zewnętrzny ruch HTTP mieszka w serwerze. Klienci nigdy nie łączą się bezpośrednio z GitHubem, Linear ani Google — wywołują operacje RPC serwera, a nawet zdalne multimedia są pobierane przez punkt końcowy /proxy/media serwera, a nie z pierwotnego źródła.

  • Klienty dio w packages/cc_infra/lib/src/: GitHubApiClient, GitHubPrClient, GitHubContentClient, GitHubGraphQLClient, GitLabApiClient, BitbucketApiClient, LinearGraphQlClient i GoogleCalendarApiClient
  • Wstrzykiwanie tokenów uwierzytelniania przez interceptory, w tym interceptor Google OAuth dla poszczególnych kont w cc_server_core, który odświeża tokeny przy kodzie 401
  • Wszystkie błędy mapowane przez packages/cc_infra/lib/src/network/error_mapper.dart → typowane podklasy AppException zdefiniowane w cc_domain

Serwer przechowuje poświadczenia, które mają znaczenie: tokeny GitHub i systemów zgłoszeń do uruchamiania agentów, klucze dostawców LLM oraz tokeny OAuth — wszystko to mieszka w katalogu danych serwera lub w jego środowisku. Pęk kluczy klienta wciąż przechowuje tylko jego własne poświadczenie urządzenia i klucz parowania ze zdalnym serwerem.

  • Sekrety w flutter_secure_storage (pęk kluczy macOS, menedżer poświadczeń Windows, libsecret w Linuksie) — poświadczenie urządzenia klienta i klucze parowania ze zdalnym serwerem, a nie tokeny dostawców
  • shared_preferences wyłącznie do niewrażliwych preferencji (motyw, czcionka, układ)
  • Pozostałe sekrety dostawców ze starszych instalacji są jednorazowo przekazywane serwerowi przez features/auth/providers/credential_migration.dart, a następnie usuwane z pęku kluczy klienta

Ograniczenia architektury weryfikuje test/core/architecture_constraints_test.dart, który kończy się niepowodzeniem, gdy reguły zależności zostaną naruszone.