Перейти к содержимому

Архитектура

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

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, и 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, который падает при нарушении правил зависимостей.