Архитектура
Control Center следует концепции Clean Architecture с приоритетом функций и паттерну «порты и адаптеры». Кодовая база организована вокруг бизнес-доменов (функций), а строгие правила зависимостей обеспечиваются автоматическим тестом архитектуры.
Тонкий клиент поверх headless-сервера
Заголовок раздела «Тонкий клиент поверх headless-сервера»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 с одним разрешённым lock-файлом, состоящее из двадцати одного члена — пятнадцати пакетов и шести приложений, — где корнем рабочего пространства служит приложение для настольных систем и веба (control_center). Шесть приложений — это бинарник headless-сервера (cc_server), безголовый исполнитель флота (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 (композиция приложения-сервера + среда выполнения идентичности/присутствия/флота/evals), 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/ содержит сущности и репозитории, общие для 3+ функций. Это чистый Dart без каких-либо инфраструктурных зависимостей, и именно поэтому и клиент на Flutter, и свободный от Flutter серверный бинарник могут импортировать одни и те же определения:
- Основные сущности:
Agent,AgentRunLog,Workspace,Repo,ReviewSpaceAssociation - Идентичность и совместная работа:
User,Principal(запечатанный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.
Совместная работа в реальном времени основана на авторитетном сервере + правиле «побеждает последняя запись» (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 плюс всё остальное, что принадлежит только этому рабочему пространству, так что рабочие деревья разговора и файлы агента лежат рядом с базой данных и удаляются вместе с ней. Здесь живёт почти всё: агенты, пространства, тикеты, память, конвейеры, встречи, граф кода, проверки, репозитории. Файлы открываются лениво при первом обращении через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, и CLIghне является способом аутентификации - Шлюз первоначальной настройки в
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, который падает при нарушении правил зависимостей.
Связанные понятия
Заголовок раздела «Связанные понятия»- Рабочие пространства и изоляция: как обеспечивается изоляция
- Жизненный цикл запуска агента: поток запуска
- Доменные события: взаимодействие между функциями
- Развёртывание и клиенты: модель тонкого клиента с сервером и матрица клиентов