Pular para o conteúdo

Arquitetura

O Control Center segue a Clean Architecture orientada a features, com o padrão de portas e adaptadores. A base de código é organizada em torno de domínios de negócio (as features), com regras de dependência rígidas aplicadas por um teste de arquitetura automatizado.

O Control Center tem uma arquitetura de cliente fino. Nenhum cliente abre o banco de dados — um processo headless do cc_server é o dono dos dados (um pequeno global.db mais um arquivo SQLite por espaço de trabalho, aberto sob demanda) e os serve por RPC sobre WebSocket. O app de desktop, a build web e o app complementar para telefone são todos renderizadores sobre essa única conexão RPC.

O desktop escolhe como chega ao seu servidor na primeira inicialização: local (ele inicia um cc_server nesta máquina e conversa por loopback — o padrão) ou remoto (ele se conecta a um cc_server em outro lugar por um WebSocket seguro). O cliente web é sempre remoto. O resolvedor de inicialização (bootstrap/server_backend.dart) escolhe o caminho antes de o Riverpod existir e depois substitui rpcClientProvider pelo cliente conectado — assim, cada feature lê e grava pelo servidor, e não em um banco de dados local.

A própria camada RPC é sem estado: uma sessão é autenticada, mas não está vinculada a um espaço de trabalho. Cada chamada com escopo de espaço de trabalho carrega o próprio workspace_id nos argumentos, e o dispatcher recusa uma chamada dessas sem ele. É isso que permite a dois clientes em um mesmo servidor manterem espaços de trabalho ativos diferentes sem um “workspace atual” mantido pelo servidor para discordar — e significa que o isolamento é aplicado por chamada, não por conexão.

Este é um workspace Dart de lockfile resolvido, com vinte e um membros — quinze pacotes mais seis apps — tendo o app de desktop+web (control_center) como raiz do workspace. Os seis apps são o binário do servidor headless (cc_server), o executor de frota headless (cc_worker), a PWA de telefone (cc_remote), o broker de relay WebSocket (cc_signaling_server), a galeria do Widgetbook (cc_gallery) e o host de demonstração público (cc_demo_server). Os quinze pacotes são cc_ui (design system), cc_domain (kernel compartilhado em Dart puro: entidades, objetos de valor, portas, eventos e a camada de domínio de cada feature), cc_harness (o kernel de agente embutido: um loop de agente em Dart puro — mensagens, porta de provedor, ferramentas, compactação, orientação, hooks, subagentes — sem dart:io e sem qualquer outra dependência cc_*), cc_harness_runtime (o adaptador de runtime do harness embutido: provedores de streaming exclusivos de VM, broker de credenciais OAuth/PKCE, armazenamentos de credenciais e o conjunto genérico de ferramentas — executa agentes sem nenhum CLI externo instalado), cc_rpc (transportes de cliente), cc_host (kernel do servidor), cc_data (repositórios remotos), cc_persistence (banco de dados do lado do servidor), cc_server_core (composição do app-servidor + runtime de identidade/presença/frota/avaliações), cc_infra (adaptadores exclusivos de VM), cc_mcp (ferramentas MCP), cc_mcp_client (cliente para servidores MCP externos, que conecta as ferramentas deles ao registro local), cc_markdown (mecanismo de markdown do repositório), cc_natives (folha FFI) e system_audio_capture. Consulte Implantação e clientes para ver a matriz completa de clientes e o que roda onde.

Presentation → Application/Providers → Domain ← Infrastructure
  • Camada de domínio: entidades, objetos de valor, interfaces de repositório, portas e serviços de domínio em Dart puro. Zero importações de infraestrutura (nada de dio, drift ou modelos de rede).
  • Camada de apresentação: telas, widgets, notifiers. Nenhum acesso direto a drift/DAO/camada de dados; tudo passa por providers do Riverpod → repositórios.
  • Camada de infraestrutura: implementações concretas das portas de domínio e das interfaces de repositório. Depende do domínio, nunca o contrário.

O formato completo de uma feature é:

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

Pouquíssimas features em lib/features/ realmente têm as quatro pastas, e essas lacunas são informativas, não acidentais. O kernel compartilhado absorveu o domain/ de cada feature dentro de cc_domain, e o cliente fino não contém implementações de repositório, então a maioria das features em lib/ tem apenas presentation/ e providers/, e nada mais. Um diretório data/ ou domain/ que sobrevive no cliente indica algo genuinamente do lado do cliente.

Duas exceções conhecidas merecem atenção:

  • mcp só tem providers. Sua interface de configurações e status fica em settings/, e a superfície de ferramentas em si é um pacote (cc_mcp), não uma pasta de feature.
  • orchestration e plan_studio têm apenas presentation/ e providers/.

A feature guardrails coloca sua camada de domínio em cc_domain/features/guardrails/ em vez de no app raiz, porque é o servidor que a aplica: uma regra mapeia um ActionClass (ou um commandPrefix de shell) para uma decisão — allow, prompt ou deny — em um escopo; as regras são armazenadas por espaço de trabalho, e toda ação de agente governada é resolvida pela cadeia de políticas antes de ser executada. A resolução percorre space > agent > workspace > mode preset > built-in default e o primeiro escopo com uma regra correspondente decide; o mais restritivo é apenas um critério de desempate dentro de um mesmo escopo e para combinar as várias classes que uma ação declara. A superfície voltada ao operador é Configurações → Espaço de trabalho → Permissões dos agentes, em /workspaces/<id>/settings/workspace/permissions. Consulte Barreiras para conhecer o modelo.

packages/cc_domain/lib/core/domain/ contém entidades e repositórios compartilhados por três ou mais features. É Dart puro, com zero dependências de infraestrutura, e é isso que permite que tanto o cliente Flutter quanto o binário de servidor sem Flutter importem as mesmas definições:

  • Entidades principais: Agent, AgentRunLog, Workspace, Repo, ReviewSpaceAssociation
  • Identidade e multijogador: User, Principal (sealed UserPrincipal | AgentPrincipal), WorkspaceMember, WorkspaceRole
  • Entidades de memória: MemoryFact, MemoryPolicy, AgentWorkingMemory, MemoryAccessGrant
  • Objetos de valor compartilhados: AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • Portas compartilhadas: SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • Serviços de domínio: MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + tipos de evento (workspace/agente, PR/revisão, mensagens, tickets, pipeline, orquestração, memória, calendário/reuniões, identidade e associação, observabilidade)

O Control Center é multiusuário: humanos e agentes são atores em pé de igualdade, unificados por um Principal. Um membro de espaço de trabalho é um User vinculado a um Workspace com um WorkspaceRole (owner/admin/member/viewer/guest). A associação é a fronteira de acesso — não uma chave de emparelhamento — e as concessões por repo impedem que a associação ao espaço de trabalho conceda mais privilégios do que o forge. O primeiro usuário criado em um servidor novo é dono da instalação; os demais entram por convite ou OIDC. Criar um espaço de trabalho marca esse usuário como owner.

A colaboração em tempo real usa servidor autoritativo + last-writer-wins (LWW) por campo, não um CRDT. A presença (quem está aqui, onde está, cursores, digitação, o status ao vivo de um agente e o custo em execução) é uma via efêmera que nunca é persistida; o estado durável trafega por uma base de mutação otimista + rebase no servidor + LWW. Humanos e agentes compartilham uma única lista de participantes. Com um único operador humano, a via de presença fica ociosa e nenhum elemento de interface da lista aparece — o multijogador não custa nada ao usuário solo.

As portas são interfaces abstratas na camada de domínio. Os adaptadores são implementações concretas na infraestrutura. Como o servidor headless é o dono dos dados e precisa ser compilado sem Flutter, quase todos os adaptadores são exclusivos de VM e ficam em packages/cc_infra/lib/src/, organizados por assunto:

Porta Localização do adaptador
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/ (lado do cliente: renderiza eventos do servidor localmente)
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 e GitHub Issues são adaptadores de sincronização de tickets em cc_infra/lib/src/tickets/sync/; tickets locais ficam no banco de dados do espaço de trabalho, sem TicketProviderPort.
PipelineEnginePort cc_domain features/pipelines/domain/services/ (um serviço de domínio em Dart puro — não precisa de infraestrutura)

A raiz de composição do app (di/providers.dart) vincula as portas do lado do cliente às implementações por meio de providers do Riverpod; o servidor compõe seus adaptadores em cc_server_core.

Riverpod para todo o estado do cliente:

  • Notifier<T> e AsyncNotifier<T> para estado mutável
  • FutureProvider<T> para dados assíncronos
  • Listas e linhas ao vivo chegam por assinaturas RPC como AsyncValue<List<T>> — o servidor usa .watch() do Drift internamente; nenhum cliente abre o Drift
  • As ferramentas MCP recebem dependências como parâmetros tipados de construtor, nunca Ref

Drift (SQLite), dividido em dois bancos de dados, ambos em cc_persistence:

  • GlobalDatabase (<dataDir>/global.db) — uma lista curta de tabelas genuinamente globais do servidor: o registro de espaços de trabalho, usuários/preferências/dispositivos emparelhados, conexões de SSO, configurações de servidor de toda a instalação, políticas de ação gerenciadas (um limite de toda a instalação que só pode restringir as barreiras dos espaços de trabalho), o feed de notícias por usuário, a fila da frota (workers/jobs/log de alocação), o índice workspace_routes de pré-autenticação e metadados da instalação. A inicialização abre apenas este arquivo, então o custo de partida permanece constante por mais histórico que os espaços de trabalho acumulem. Adicionar uma tabela aqui é uma decisão de isolamento que um teste de catraca obriga você a justificar.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — um diretório por espaço de trabalho, contendo um arquivo SQLite mais tudo o mais que pertence apenas àquele espaço de trabalho, de modo que os worktrees e arquivos de agente de uma conversa ficam ao lado do banco de dados e são excluídos junto com ele. Quase tudo mora aqui: agentes, espaços, tickets, memória, pipelines, reuniões, o grafo de código, revisões, repos. Os arquivos são abertos sob demanda no primeiro acesso, por meio de WorkspaceDatabaseManager.of(workspaceId), e nesse momento pagam seu próprio quick_check, a instalação de FTS/triggers e o vector_init.

As duas metades começaram de uma base v1 consolidada — o onCreate constrói o esquema atual — com etapas de migração acrescentadas apenas quando um banco de dados já implantado precisa ser levado adiante.

Como um espaço de trabalho é um arquivo, exportar um espaço de trabalho é uma única instrução VACUUM INTO (workspace.export), e não um dump tabela por tabela; workspace.import adota esse arquivo e usa o workspace_meta embutido para distinguir se o próprio arquivo foi readotado ou se veio de outra instalação.

Ambos os bancos de dados pertencem a cc_persistence e são abertos somente pelo cc_server; nenhum cliente abre um BD — todo cliente lê e grava por RPC. As tabelas são definidas em packages/cc_persistence/lib/database/tables/, os DAOs em .../daos/; as entidades de domínio são Dart puro, separadas das classes de tabela do Drift, com o mapeamento nas camadas de dados. O FTS5 cuida da busca em texto completo e o sqlite_vector dos embeddings, ambos exclusivos do servidor; os dois bancos de dados rodam PRAGMA foreign_keys=ON em modo de journal WAL.

go_router, com um fato que governa todo o resto: todo destino dentro do app tem o prefixo do espaço de trabalho, no formato /workspaces/:workspaceId/…. O id do espaço de trabalho na URL é a única fonte de verdade do espaço de trabalho ativo — o activeWorkspaceIdProvider é determinado pela rota, e não o contrário — por isso ler dados com escopo de espaço de trabalho fora de uma rota com prefixo não tem de onde tirar o id.

  • Funções construtoras de rota em router/routes.dart, cada uma recebendo o id do espaço de trabalho como primeiro argumento, e não constantes de string
  • Quatro rotas anteriores ao contexto não têm prefixo: /splash, /onboarding, /signed-out e /workspaces (o seletor)
  • O ShellRoute envolve o shell do app; essas quatro superfícies são renderizadas em tela cheia fora dele
  • Guarda de autenticação: uma configuração incompleta vai para /onboarding; uma configuração concluída cuja credencial de forge sumiu vai para /signed-out. Concluída significa que pelo menos um forge está conectado para o usuário autenticado e que existe pelo menos um espaço de trabalho — não especificamente o GitHub — e o CLI gh não é um método de autenticação
  • Portão de onboarding em features/auth/providers/

Todo HTTP externo vive no servidor. Os clientes nunca se conectam diretamente ao GitHub, ao Linear ou ao Google — eles chamam operações RPC do servidor, e até mídia remota é obtida pelo endpoint /proxy/media do servidor, e não da origem upstream.

  • Clientes dio em packages/cc_infra/lib/src/: GitHubApiClient, GitHubPrClient, GitHubContentClient, GitHubGraphQLClient, GitLabApiClient, BitbucketApiClient, LinearGraphQlClient e GoogleCalendarApiClient
  • Injeção do token de autenticação por interceptadores, incluindo um interceptador de OAuth do Google por conta em cc_server_core que renova os tokens em respostas 401
  • Todos os erros são mapeados por packages/cc_infra/lib/src/network/error_mapper.dart → subclasses tipadas de AppException definidas em cc_domain

O servidor guarda as credenciais que importam: tokens do GitHub e dos sistemas de tickets para lançamentos de agentes, chaves de provedores de LLM e tokens OAuth ficam todos sob o diretório de dados do servidor ou em seu ambiente. O que o keychain do cliente ainda guarda é a própria credencial do dispositivo e a chave de emparelhamento de um servidor remoto.

  • Segredos em flutter_secure_storage (keychain do macOS, gerenciador de credenciais do Windows, libsecret do Linux) — a credencial de dispositivo do cliente e as chaves de emparelhamento remoto, não tokens de provedores
  • shared_preferences apenas para preferências não sensíveis (tema, fonte, layout)
  • Segredos de provedor remanescentes de instalações mais antigas são entregues ao servidor uma única vez por features/auth/providers/credential_migration.dart e depois excluídos do keychain do cliente

As restrições de arquitetura são validadas por test/core/architecture_constraints_test.dart, que falha se as regras de dependência forem violadas.