Перейти до вмісту

Архітектура

Control Center дотримується Clean Architecture з первинністю фіч (feature-first) і патерном «порти та адаптери». Кодову базу організовано навколо бізнес-доменів (фіч), а суворі правила залежностей перевіряє автоматизований тест архітектури.

Control Center має архітектуру тонкого клієнта. Жоден клієнт не відкриває базу даних — дані належать headless-процесу cc_server (невелика база global.db плюс один файл SQLite на робочий простір, що відкривається ліниво), і він віддає їх через WebSocket RPC. Десктопний застосунок, вебзбірка та застосунок-компаньйон для телефона — усі вони лише рендерери поверх цього єдиного RPC-з’єднання.

Під час першого запуску десктоп обирає, як він з’єднується зі своїм сервером: локально (він запускає cc_server на цій машині та спілкується через loopback — типовий варіант) або віддалено (він під’єднується до cc_server деінде через захищене WebSocket-з’єднання). Вебклієнт завжди працює віддалено. Розв’язувач завантаження (bootstrap/server_backend.dart) обирає шлях ще до появи Riverpod, а потім перевизначає rpcClientProvider під’єднаним клієнтом — тож кожна фіча читає й записує через сервер, а не через локальну базу даних.

Сам шар RPC є безстановим: сесія проходить автентифікацію, але не прив’язана до робочого простору. Кожен виклик у межах робочого простору передає власний workspace_id у своїх аргументах, і диспетчер відхиляє такий виклик без нього. Саме це дає змогу двом клієнтам на одному сервері тримати різні активні робочі простори без серверного «поточного робочого простору», щодо якого вони могли б розійтися, — і означає, що ізоляція забезпечується на рівні виклику, а не з’єднання.

Це Dart-воркспейс з єдиним зафіксованим lockfile, що налічує двадцять одного члена — п’ятнадцять пакетів і шість застосунків, — а коренем воркспейсу є застосунок для десктопа й вебу (control_center). Шість застосунків — це бінарник headless-сервера (cc_server), headless-виконавець флоту (cc_worker), PWA для телефона (cc_remote), брокер-ретранслятор WebSocket (cc_signaling_server), галерея Widgetbook (cc_gallery) і хост публічної демонстрації (cc_demo_server). П’ятнадцять пакетів — це cc_ui (система дизайну), cc_domain (спільне ядро на чистому Dart: сутності, об’єкти-значення, порти, події та доменний шар кожної фічі), cc_harness (вбудоване ядро агента: цикл агента на чистому Dart — повідомлення, порт провайдера, інструменти, компакція, керування, хуки, субагенти — без dart:io і без жодної іншої залежності cc_*), cc_harness_runtime (адаптер середовища виконання вбудованого harness: потокові провайдери лише для VM, брокеринг облікових даних OAuth/PKCE, сховища облікових даних і загальний набір інструментів — запускає агентів без установленого зовнішнього CLI), cc_rpc (транспорти клієнта), cc_host (ядро сервера), cc_data (віддалені репозиторії), cc_persistence (серверна БД), cc_server_core (композиція застосунку й сервера + середовище виконання ідентичності/присутності/флоту/eval), cc_infra (адаптери лише для VM), cc_mcp (інструменти MCP), cc_mcp_client (клієнт для зовнішніх MCP-серверів, який приєднує їхні інструменти до локального реєстру), cc_markdown (рушій markdown у репозиторії), cc_natives (листовий пакет FFI) і system_audio_capture. Про повну матрицю клієнтів і те, що де працює, див. Розгортання та клієнти.

Presentation → Application/Providers → Domain ← Infrastructure
  • Доменний шар: сутності, об’єкти-значення, інтерфейси репозиторіїв, порти й доменні сервіси на чистому Dart. Жодних імпортів з інфраструктури (ані dio, ані drift, ані мережевих моделей).
  • Шар представлення: екрани, віджети, нотифікатори. Без прямого доступу до drift, DAO чи шару даних; усе проходить через провайдери Riverpod → репозиторії.
  • Інфраструктурний шар: конкретні реалізації доменних портів та інтерфейсів репозиторіїв. Залежить від домену й ніколи навпаки.

Повна структура фічі така:

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

Дуже мало фіч під lib/features/ справді мають усі чотири каталоги, і ці прогалини радше інформативні, ніж випадкові. Спільне ядро поглинуло domain/ кожної фічі в cc_domain, а тонкий клієнт не містить реалізацій репозиторіїв, тож більшість фіч під lib/ — це presentation/ плюс providers/ і більше нічого. Каталог data/ або domain/, що залишився в клієнті, позначає щось справді клієнтське.

Варто знати про два винятки з назвами:

  • mcp містить лише провайдери. Його інтерфейс налаштувань і статусу живе під settings/, а сам набір інструментів — це пакет (cc_mcp), а не тека фічі.
  • orchestration і plan_studio містять лише presentation/ і providers/.

Фіча guardrails виносить свій доменний шар у cc_domain/features/guardrails/, а не в кореневий застосунок, бо його забезпечує сервер: правило зіставляє ActionClass (або shell-commandPrefix) із рішенням — allow, prompt чи deny — у певній області застосування, правила зберігаються для кожного робочого простору, і кожна підконтрольна дія агента перед запуском розв’язується через ланцюжок політик. Розв’язання проходить шлях space > agent > workspace > mode preset > built-in default, і вирішує перша область застосування з відповідним правилом; найсуворіше правило лише розв’язує нічию в межах однієї області та потрібне для об’єднання кількох класів, які оголошує одна дія. Інтерфейс для оператора — Налаштування → Робочий простір → Дозволи агента, за шляхом /workspaces/<id>/settings/workspace/permissions. Модель описано в розділі Обмеження.

packages/cc_domain/lib/core/domain/ містить сутності та репозиторії, спільні для трьох і більше фіч. Це чистий Dart без жодних інфраструктурних залежностей — саме завдяки цьому і Flutter-клієнт, і серверний бінарник без Flutter можуть імпортувати ті самі визначення:

  • Основні сутності: Agent, AgentRunLog, Workspace, Repo, ReviewSpaceAssociation
  • Ідентичність і спільна робота: User, Principal (sealed UserPrincipal | AgentPrincipal), WorkspaceMember, WorkspaceRole
  • Сутності пам’яті: MemoryFact, MemoryPolicy, AgentWorkingMemory, MemoryAccessGrant
  • Спільні об’єкти-значення: AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • Спільні порти: SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • Доменні сервіси: MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + типи подій (робочий простір/агент, PR/перевірка, повідомлення, тікети, конвеєр, оркестрація, пам’ять, календар/зустрічі, ідентичність і членство, спостережуваність)

Control Center — багатокористувацька система: люди й агенти є рівноправними дійовими особами, об’єднаними через Principal. Учасник робочого простору — це User, прив’язаний до Workspace у межах WorkspaceRole (owner/admin/member/viewer/guest). Межа доступу — це членство, а не ключ прив’язки, і дозволи на рівні репозиторію не дають членству в робочому просторі перевищити права хоста коду. Перший користувач, створений на чистому сервері, володіє інсталяцією; інші приєднуються за запрошенням або через OIDC. Створення робочого простору позначає цього користувача як owner.

Спільна робота в реальному часі — це авторитетний сервер + last-writer-wins (LWW) для кожного поля, а не CRDT. Присутність (хто тут, де саме, курсори, набір тексту, поточний статус агента й поточні витрати) — це окрема ефемерна смуга, яка ніколи не зберігається; довготривалий стан тримається на основі оптимістичних змін + повторного узгодження на сервері + LWW. Люди й агенти поділяють один реєстр. Коли оператор лише один, смуга присутності простоює і жодних елементів інтерфейсу реєстру не з’являється — спільна робота нічого не коштує користувачу-одинаку.

Порти — це абстрактні інтерфейси в доменному шарі. Адаптери — конкретні реалізації в інфраструктурі. Оскільки headless-сервер володіє даними й має збиратися без Flutter, майже кожен адаптер призначений лише для VM і лежить у packages/cc_infra/lib/src/, згрупований за призначенням:

Порт Розташування адаптера
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/ (на боці клієнта: локально рендерить серверні події)
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 і GitHub Issues — це адаптери синхронізації тікетів у cc_infra/lib/src/tickets/sync/; локальні тікети живуть у базі даних робочого простору без TicketProviderPort.
PipelineEnginePort cc_domain features/pipelines/domain/services/ (доменний сервіс на чистому Dart — інфраструктура йому не потрібна)

Корінь композиції застосунку (di/providers.dart) зв’язує клієнтські порти з реалізаціями через провайдери Riverpod; сервер збирає свої адаптери в cc_server_core.

Riverpod для всього стану клієнта:

  • Notifier<T> і AsyncNotifier<T> для змінюваного стану
  • FutureProvider<T> для асинхронних даних
  • Списки та живі рядки надходять через RPC-підписки як AsyncValue<List<T>> — усередині сервер використовує Drift .watch(); жоден клієнт не відкриває Drift
  • Інструменти MCP отримують залежності як типізовані параметри конструктора, ніколи не Ref

Drift (SQLite), розділений на дві бази даних, обидві в cc_persistence:

  • GlobalDatabase (<dataDir>/global.db) — короткий список таблиць, справді спільних для всього сервера: реєстр робочих просторів, користувачі/налаштування/прив’язані пристрої, підключення SSO, серверні налаштування всієї інсталяції, керовані політики дій (затискач на рівні інсталяції, який може лише посилювати обмеження робочих просторів), стрічка новин для кожного користувача, черга флоту (воркери/завдання/журнал розміщення), індекс workspace_routes до автентифікації та метадані інсталяції. Завантаження відкриває лише цей файл, тож витрати на запуск залишаються незмінними, скільки б історії не накопичили робочі простори. Додавання таблиці сюди — це рішення про ізоляцію, яке ratchet-тест змушує вас обґрунтувати.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — один каталог на робочий простір, що містить один файл SQLite та все інше, що належить лише цьому робочому простору, тож worktree розмови й файли агентів лежать поряд із базою даних і видаляються разом із нею. Тут живе майже все: агенти, простори, тікети, пам’ять, конвеєри, зустрічі, граф коду, перевірки, репозиторії. Файли відкриваються ліниво при першому зверненні через WorkspaceDatabaseManager.of(workspaceId) і тоді ж виконують власні quick_check, встановлення FTS/тригерів та vector_init.

Обидві половини починалися зі зведеної базової версії v1 — onCreate будує поточну схему — а кроки міграції додаються лише тоді, коли розгорнуту базу даних треба перенести далі.

Оскільки один робочий простір — це один файл, експорт робочого простору є одним виразом VACUUM INTO (workspace.export), а не дампом таблиця за таблицею; workspace.import приймає такий файл і за вбудованим workspace_meta розрізняє повторно прийнятий власний файл від файла з іншої інсталяції.

Обидві бази даних належать cc_persistence, і відкриває їх лише cc_server; жоден клієнт не відкриває БД — кожен клієнт читає й записує через RPC. Таблиці визначено в packages/cc_persistence/lib/database/tables/, DAO — у .../daos/; доменні сутності — це чистий Dart, окремо від класів таблиць Drift, а зіставлення відбувається в шарах даних. FTS5 відповідає за повнотекстовий пошук, а sqlite_vector — за ембеддинги; і те, й інше працює лише на сервері. Обидві бази даних працюють із PRAGMA foreign_keys=ON у режимі журналу WAL.

go_router, і одна особливість визначає все інше: кожен внутрішній маршрут застосунку має префікс робочого простору у вигляді /workspaces/:workspaceId/…. Ідентифікатор робочого простору в URL — єдине джерело істини для активного робочого простору: activeWorkspaceIdProvider керується маршрутом, а не навпаки, — саме тому читання даних у межах робочого простору поза маршрутом із префіксом не має звідки взяти його ідентифікатор.

  • Функції-білдери маршрутів у router/routes.dart, кожна бере ідентифікатор робочого простору як перший аргумент, а не рядкові константи
  • Чотири маршрути до контексту не мають префікса: /splash, /onboarding, /signed-out і /workspaces (засіб вибору)
  • ShellRoute обгортає оболонку застосунку; ці чотири екрани рендеряться на весь екран поза нею
  • Вартовий автентифікації: незавершене налаштування веде на /onboarding; завершене налаштування, у якого зникли облікові дані хоста коду, веде на /signed-out. Завершене означає, що для користувача, який увійшов, підключено щонайменше один хост коду і існує щонайменше один робочий простір — не обов’язково саме GitHub, і CLI gh не є способом автентифікації
  • Шлюз онбордингу в features/auth/providers/

Увесь зовнішній HTTP живе на сервері. Клієнти ніколи не звертаються до GitHub, Linear чи Google напряму — вони викликають RPC-операції сервера, і навіть віддалені медіа завантажуються через серверну кінцеву точку /proxy/media, а не з початкового джерела.

  • Клієнти dio в packages/cc_infra/lib/src/: GitHubApiClient, GitHubPrClient, GitHubContentClient, GitHubGraphQLClient, GitLabApiClient, BitbucketApiClient, LinearGraphQlClient і GoogleCalendarApiClient
  • Вставлення токенів автентифікації через перехоплювачі, зокрема перехоплювач Google OAuth для кожного облікового запису в cc_server_core, який оновлює токени при 401
  • Усі помилки проходять через packages/cc_infra/lib/src/network/error_mapper.dart → типізовані підкласи AppException, визначені в cc_domain

Сервер зберігає облікові дані, які мають значення: токени GitHub і тікет-систем для запусків агентів, ключі провайдерів LLM і токени OAuth живуть у каталозі даних сервера або в його середовищі. У ланцюжку ключів клієнта залишаються лише його власні облікові дані пристрою та ключ прив’язки до віддаленого сервера.

  • Секрети у flutter_secure_storage (ланцюжок ключів macOS, диспетчер облікових даних Windows, libsecret у Linux) — облікові дані пристрою клієнта та ключі прив’язки до віддаленого сервера, а не токени провайдерів
  • shared_preferences лише для нечутливих налаштувань (тема, шрифт, компонування)
  • Секрети провайдерів, що залишилися від старіших інсталяцій, features/auth/providers/credential_migration.dart один раз передає серверу, а потім видаляє з ланцюжка ключів клієнта

Обмеження архітектури перевіряє test/core/architecture_constraints_test.dart; він падає, якщо правила залежностей порушено.