Saltar para o conteúdo

Arquitetura

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

O Control Center tem uma arquitetura de cliente fino. Nenhum cliente abre a base de dados — um processo headless cc_server é o dono dos dados (um pequeno global.db mais um ficheiro SQLite por espaço de trabalho, abertos de forma preguiçosa) e serve-os por RPC sobre WebSocket. A aplicação para computador, a versão web e a aplicação complementar para telemóvel são todas renderizadores sobre essa única ligação RPC.

A aplicação para computador escolhe como chega ao seu servidor no primeiro arranque: local (lança um cc_server nesta máquina e comunica por loopback — a predefinição) ou remoto (liga-se a um cc_server noutro local através de um WebSocket seguro). O cliente web é sempre remoto. O resolvedor de arranque (bootstrap/server_backend.dart) escolhe o caminho antes de o Riverpod existir e, de seguida, substitui rpcClientProvider pelo cliente ligado — por isso cada funcionalidade lê e escreve através do servidor, e não de uma base 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 âmbito de espaço de trabalho transporta o seu próprio workspace_id nos argumentos, e o despachante recusa uma chamada dessas sem ele. É isso que permite a dois clientes num mesmo servidor manterem espaços de trabalho ativos diferentes sem um “espaço de trabalho atual” detido pelo servidor sobre o qual possam discordar — e significa que o isolamento é imposto por chamada, não por ligação.

Este é um único Dart workspace com lockfile resolvido, de vinte e um membros — quinze pacotes mais seis aplicações — tendo a aplicação para computador e web (control_center) como raiz do espaço de trabalho. As seis aplicações são o binário do servidor headless (cc_server), o executor headless da frota (cc_worker), a PWA de telemóvel (cc_remote), o broker de retransmissão WebSocket (cc_signaling_server), a galeria do Widgetbook (cc_gallery) e o anfitrião de demonstração público (cc_demo_server). Os quinze pacotes são cc_ui (sistema de design), cc_domain (kernel partilhado em Dart puro: entidades, objetos de valor, portas, eventos e a camada de domínio de cada funcionalidade), cc_harness (o kernel de agente integrado: um ciclo de agente em Dart puro — mensagens, porta de fornecedor, 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 integrado: fornecedores de streaming exclusivos de VM, intermediação 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 (base de dados do lado do servidor), cc_server_core (composição aplicação-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 liga as suas ferramentas ao registo local), cc_markdown (motor de markdown do repositório), cc_natives (folha FFI) e system_audio_capture. Consulte Implementação e clientes para ver a matriz completa de clientes e o que é executado 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: ecrãs, 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 funcionalidade é:

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

Muito poucas funcionalidades em lib/features/ têm, na realidade, as quatro pastas, e essas lacunas são informativas, não acidentais. O kernel partilhado absorveu o domain/ de cada funcionalidade em cc_domain, e o cliente fino não contém implementações de repositório, pelo que a maioria das funcionalidades em lib/ tem apenas presentation/ e providers/, e nada mais. Um diretório data/ ou domain/ que sobreviva no cliente assinala algo genuinamente do lado do cliente.

Duas exceções com nome merecem atenção:

  • mcp só tem providers. A sua interface de definições e de estado fica em settings/, e a própria superfície de ferramentas é um pacote (cc_mcp), não uma pasta de funcionalidade.
  • orchestration e plan_studio têm apenas presentation/ e providers/.

A funcionalidade guardrails coloca a sua camada de domínio em cc_domain/features/guardrails/ em vez de na aplicação 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 — num determinado âmbito, as regras são guardadas por espaço de trabalho e cada ação de agente regulada é 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 âmbito com uma regra correspondente decide; o mais restritivo é apenas um critério de desempate dentro de um mesmo âmbito e para combinar as várias classes que uma ação declara. A superfície visível ao operador é Definiçõ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 partilhados por três ou mais funcionalidades. É Dart puro, com zero dependências de infraestrutura, e é isso que permite que tanto o cliente Flutter como 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 partilhados: AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • Portas partilhadas: SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • Serviços de domínio: MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + tipos de evento (espaço de trabalho/agente, PR/revisão, mensagens, tickets, pipeline, orquestração, memória, calendário/reuniões, identidade e pertença, observabilidade)

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

A colaboração em tempo real é servidor autoritativo + última escrita vence (LWW) por campo, não um CRDT. A presença (quem está aqui, onde está, cursores, quem está a escrever, o estado em direto e o custo em curso de um agente) é um canal efémero separado que nunca é persistido; o estado duradouro assenta numa espinha dorsal de mutação otimista + rebase no servidor + LWW. Humanos e agentes partilham uma única lista de participantes. Com um único operador humano, o canal de presença fica inativo e não aparece qualquer elemento de interface da lista de participantes — o multijogador não custa nada ao utilizador individual.

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

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/ (do 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/; os tickets locais ficam na base de dados do espaço de trabalho, sem qualquer 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 da aplicação (di/providers.dart) liga as portas do lado do cliente às implementações através de providers do Riverpod; o servidor compõe os 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 em direto chegam por subscrições RPC como AsyncValue<List<T>> — o servidor usa o .watch() do Drift internamente; nenhum cliente abre o Drift
  • As ferramentas MCP recebem as dependências como parâmetros tipados de construtor, nunca Ref

Drift (SQLite), dividido em duas bases de dados, ambas em cc_persistence:

  • GlobalDatabase (<dataDir>/global.db) — uma lista curta de tabelas genuinamente globais ao servidor: o registo de espaços de trabalho, utilizadores/preferências/dispositivos emparelhados, ligações SSO, definições de servidor de toda a instalação, políticas de ação geridas (um limite de toda a instalação que só pode restringir as barreiras dos espaços de trabalho), o feed de notícias por utilizador, a fila da frota (workers/jobs/registo de colocação), o índice workspace_routes de pré-autenticação e os metadados da instalação. O arranque abre apenas este ficheiro, pelo que o custo de arranque se mantém constante por muito histórico que os espaços de trabalho acumulem. Adicionar uma tabela aqui é uma decisão de isolamento que um teste de catraca o obriga a justificar.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — um diretório por espaço de trabalho, que contém um ficheiro SQLite mais tudo o resto que pertence apenas a esse espaço de trabalho, de modo que os worktrees e os ficheiros de agente de uma conversa ficam ao lado da base de dados e são eliminados com ela. Quase tudo vive aqui: agentes, espaços, tickets, memória, pipelines, reuniões, o grafo de código, revisões, repos. Os ficheiros são abertos de forma preguiçosa no primeiro acesso, através de WorkspaceDatabaseManager.of(workspaceId), e pagam nessa altura o seu próprio quick_check, a instalação de FTS/triggers e o vector_init.

As duas metades começaram a partir de uma base v1 consolidada — o onCreate constrói o esquema atual —, com passos de migração acrescentados apenas quando uma base de dados já implementada tem de ser levada adiante.

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

Ambas as bases de dados pertencem a cc_persistence e são abertas apenas pelo cc_server; nenhum cliente abre uma base de dados — todos os clientes leem e escrevem 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 trata da pesquisa de texto integral e o sqlite_vector dos embeddings, ambos exclusivos do servidor; ambas as bases de dados executam PRAGMA foreign_keys=ON em modo de journal WAL.

go_router, com um facto que governa todo o resto: todos os destinos dentro da aplicação têm o prefixo do espaço de trabalho, no formato /workspaces/:workspaceId/…. O id do espaço de trabalho no URL é a única fonte de verdade do espaço de trabalho ativo — o activeWorkspaceIdProvider é determinado pela rota, e não o contrário — e é por isso que ler dados com âmbito de espaço de trabalho fora de uma rota com prefixo não tem de onde obter o id.

  • Funções construtoras de rotas 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 da aplicação; essas quatro superfícies são renderizadas em ecrã inteiro fora dele
  • Guarda de autenticação: uma configuração incompleta vai para /onboarding; uma configuração concluída cuja credencial de forge desapareceu vai para /signed-out. Concluída significa que pelo menos uma forge está ligada para o utilizador com sessão iniciada 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 o HTTP externo vive no servidor. Os clientes nunca se ligam diretamente ao GitHub, ao Linear ou ao Google — chamam operações RPC do servidor, e até os conteúdos multimédia remotos são obtidos através do endpoint /proxy/media do servidor, e não da origem a montante.

  • 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 interceptores, incluindo um interceptor de OAuth do Google por conta em cc_server_core que renova os tokens em respostas 401
  • Todos os erros são mapeados através de 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 fornecedores de LLM e tokens OAuth ficam todos sob o diretório de dados do servidor ou no seu ambiente. O que o keychain do cliente ainda guarda é a sua própria credencial de dispositivo e a chave de emparelhamento de um servidor remoto.

  • Segredos em flutter_secure_storage (keychain do macOS, gestor de credenciais do Windows, libsecret do Linux) — a credencial de dispositivo do cliente e as chaves de emparelhamento remoto, não tokens de fornecedores
  • shared_preferences apenas para preferências não sensíveis (tema, tipo de letra, layout)
  • Segredos de fornecedor remanescentes de instalações mais antigas são entregues ao servidor uma única vez por features/auth/providers/credential_migration.dart e depois eliminados 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.