Ir al contenido

Arquitectura

Control Center sigue Clean Architecture centrada en funcionalidades con un patrón de puertos y adaptadores. El código se organiza en torno a dominios de negocio (funcionalidades), con reglas de dependencia estrictas que hace cumplir un test de arquitectura automatizado.

Un cliente ligero sobre un servidor headless

Sección titulada «Un cliente ligero sobre un servidor headless»

Control Center es una arquitectura de cliente ligero. Ningún cliente abre la base de datos: un proceso headless cc_server es el propietario de los datos (un pequeño global.db más un archivo SQLite por espacio de trabajo, abiertos de forma perezosa) y los sirve por RPC sobre WebSocket. La aplicación de escritorio, la versión web y el compañero para el teléfono son todos representadores sobre esa única conexión RPC.

El escritorio elige cómo llega a su servidor en el primer arranque: local (lanza un cc_server en esta máquina y habla por loopback —el valor predeterminado—) o remoto (marca un cc_server en otro lugar por un WebSocket seguro). El cliente web siempre es remoto. El resolutor de arranque (bootstrap/server_backend.dart) elige la ruta antes de que exista Riverpod y después sustituye rpcClientProvider por el cliente conectado, de modo que cada funcionalidad lee y escribe a través del servidor en lugar de hacerlo en una base de datos local.

La propia capa RPC es sin estado: una sesión se autentica, pero no está vinculada a un espacio de trabajo. Toda llamada con ámbito de espacio de trabajo lleva su propio workspace_id en sus argumentos, y el despachador rechaza una llamada así si no lo lleva. Eso es lo que permite que dos clientes de un mismo servidor mantengan espacios de trabajo activos distintos sin un «espacio de trabajo actual» alojado en el servidor con el que puedan discrepar, y significa que el aislamiento se aplica por llamada, no por conexión.

Este es un único workspace de Dart con lockfile resuelto de veintiún miembros —quince paquetes más seis aplicaciones—, con la aplicación de escritorio y web (control_center) como raíz del workspace. Las seis aplicaciones son el binario del servidor headless (cc_server), el ejecutor headless de flota (cc_worker), la PWA del teléfono (cc_remote), el bróker de retransmisión WebSocket (cc_signaling_server), la galería de Widgetbook (cc_gallery) y el host de demostración público (cc_demo_server). Los quince paquetes son cc_ui (sistema de diseño), cc_domain (kernel compartido en Dart puro: entidades, objetos de valor, puertos, eventos y la capa de dominio de cada funcionalidad), cc_harness (el kernel de agente integrado: un bucle de agente en Dart puro —mensajes, puerto de proveedor, herramientas, compactación, dirección, hooks, subagentes— sin dart:io ni ninguna otra dependencia de cc_*), cc_harness_runtime (el adaptador de tiempo de ejecución del harness integrado: proveedores de streaming solo para la VM, intermediación de credenciales OAuth/PKCE, almacenes de credenciales y el conjunto genérico de herramientas —ejecuta agentes sin ningún CLI externo instalado—), cc_rpc (transportes de cliente), cc_host (kernel del servidor), cc_data (repositorios remotos), cc_persistence (base de datos del lado del servidor), cc_server_core (composición de la aplicación y el servidor

  • tiempo de ejecución de identidad/presencia/flota/evaluaciones), cc_infra (adaptadores solo para la VM), cc_mcp (herramientas MCP), cc_mcp_client (cliente para servidores MCP externos, que conecta sus herramientas con el registro local), cc_markdown (motor de markdown integrado en el repositorio), cc_natives (hoja FFI) y system_audio_capture. Consulta Despliegue y clientes para ver la matriz completa de clientes y qué se ejecuta dónde.
Presentation → Application/Providers → Domain ← Infrastructure
  • Capa de dominio: entidades, objetos de valor, interfaces de repositorio, puertos y servicios de dominio en Dart puro. Cero importaciones de infraestructura (ni dio, ni drift, ni modelos de red).
  • Capa de presentación: pantallas, widgets, notificadores. Sin acceso directo a drift/DAO/capa de datos; todo pasa por los proveedores de Riverpod → repositorios.
  • Capa de infraestructura: implementaciones concretas de los puertos de dominio y de las interfaces de repositorio. Depende del dominio, nunca al revés.

La forma completa de una funcionalidad es:

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

Muy pocas funcionalidades bajo lib/features/ llevan realmente las cuatro, y esos huecos son informativos en lugar de accidentales. El kernel compartido absorbió el domain/ de cada funcionalidad en cc_domain, y el cliente ligero no contiene implementaciones de repositorio, así que la mayoría de las funcionalidades bajo lib/ son presentation/ más providers/ y nada más. Un directorio data/ o domain/ que sobreviva en el cliente marca algo genuinamente del lado del cliente.

Hay dos excepciones con nombre propio que conviene conocer:

  • mcp solo tiene proveedores. Su interfaz de ajustes y estado vive en settings/, y la propia superficie de herramientas es un paquete (cc_mcp), no una carpeta de funcionalidad.
  • orchestration y plan_studio solo llevan presentation/ y providers/.

La funcionalidad guardrails coloca su capa de dominio en cc_domain/features/guardrails/ en lugar de en la aplicación raíz, porque el servidor es quien la aplica: una regla asigna un ActionClass (o un commandPrefix de shell) a una decisión —allow, prompt o deny— en un ámbito; las reglas se guardan por espacio de trabajo y toda acción de agente gobernada se resuelve por la cadena de políticas antes de ejecutarse. La resolución recorre space > agent > workspace > mode preset > built-in default y decide el primer ámbito con una regla coincidente; lo más restrictivo solo es un desempate dentro de un ámbito y para combinar las varias clases que declara una acción. La superficie de cara al operador es Ajustes → Espacio de trabajo → Permisos de agentes, en /workspaces/<id>/settings/workspace/permissions. Consulta Barandillas para conocer el modelo.

packages/cc_domain/lib/core/domain/ contiene entidades y repositorios compartidos por 3 o más funcionalidades. Es Dart puro con cero dependencias de infraestructura, que es lo que permite que tanto el cliente de Flutter como el binario de servidor sin Flutter importen las mismas definiciones:

  • Entidades principales: Agent, AgentRunLog, Workspace, Repo, ReviewSpaceAssociation
  • Identidad y multijugador: User, Principal (UserPrincipal | AgentPrincipal sellados), WorkspaceMember, WorkspaceRole
  • Entidades de memoria: MemoryFact, MemoryPolicy, AgentWorkingMemory, MemoryAccessGrant
  • Objetos de valor compartidos: AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • Puertos compartidos: SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • Servicios de dominio: MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + tipos de evento (espacio de trabajo/agente, PR/revisión, mensajería, tickets, pipeline, orquestación, memoria, calendario/reuniones, identidad y pertenencia, observabilidad)

Control Center es multiusuario: las personas y los agentes son actores en pie de igualdad unificados por un Principal. Un miembro de un espacio de trabajo es un User vinculado a un Workspace con un WorkspaceRole (owner/admin/member/viewer/guest). La pertenencia es la frontera de acceso —no una clave de emparejamiento—, y las concesiones por repositorio impiden que la pertenencia a un espacio de trabajo otorgue por sí sola más privilegios que el host de código. El primer usuario creado en un servidor nuevo es el propietario de la instalación; los demás se incorporan por invitación u OIDC. Al crear un espacio de trabajo, ese usuario queda marcado como owner.

La colaboración en tiempo real es servidor autoritativo + última escritura gana (LWW) por campo, no un CRDT. La presencia (quién está aquí, dónde está, cursores, escritura, el estado en vivo de un agente y su coste en ejecución) es un carril efímero aparte que nunca se persiste; el estado duradero se apoya en una columna vertebral de mutación optimista + rebase en el servidor + LWW. Las personas y los agentes comparten un mismo listado. Con un único operador humano, el carril de presencia queda inactivo y no aparece ningún elemento de interfaz del listado: el multijugador no le cuesta nada al usuario en solitario.

Los puertos son interfaces abstractas de la capa de dominio. Los adaptadores son implementaciones concretas en la infraestructura. Como el servidor headless es el propietario de los datos y tiene que compilarse sin Flutter, casi todos los adaptadores son solo para la VM y viven en packages/cc_infra/lib/src/, organizados por asunto:

Port Adapter location
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/ (del lado del cliente: representa los eventos del servidor en local)
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 y GitHub Issues son adaptadores de sincronización de tickets en cc_infra/lib/src/tickets/sync/; los tickets locales viven en la base de datos del espacio de trabajo sin ningún TicketProviderPort.
PipelineEnginePort cc_domain features/pipelines/domain/services/ (un servicio de dominio en Dart puro: no necesita infraestructura)

La raíz de composición de la aplicación (di/providers.dart) vincula los puertos del lado del cliente con sus implementaciones mediante proveedores de Riverpod; el servidor compone sus adaptadores en cc_server_core.

Riverpod para todo el estado del cliente:

  • Notifier<T> y AsyncNotifier<T> para estado mutable
  • FutureProvider<T> para datos asíncronos
  • Las listas y las filas en vivo llegan por suscripciones RPC como AsyncValue<List<T>>: el servidor usa Drift .watch() internamente; ningún cliente abre Drift
  • Las herramientas MCP reciben sus dependencias como parámetros de constructor tipados, nunca como Ref

Drift (SQLite), dividida en dos bases de datos, ambas en cc_persistence:

  • GlobalDatabase (<dataDir>/global.db): una lista corta de tablas genuinamente de todo el servidor: el registro de espacios de trabajo, usuarios/preferencias/dispositivos emparejados, conexiones SSO, ajustes del servidor de toda la instalación, políticas de acciones gestionadas (un límite de toda la instalación que solo puede apretar las barandillas del espacio de trabajo), el canal de noticias por usuario, la cola de la flota (workers/jobs/registro de colocación), el índice workspace_routes previo a la autenticación y los metadatos de la instalación. El arranque abre solo este archivo, así que el coste de inicio se mantiene plano por mucha historia que acumulen los espacios de trabajo. Añadir una tabla aquí es una decisión de aislamiento que un test de trinquete te obliga a justificar.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db): un directorio por espacio de trabajo, que contiene un archivo SQLite más todo lo demás que pertenece solo a ese espacio de trabajo, de modo que los worktrees y los archivos de agente de una conversación quedan junto a la base de datos y se eliminan con ella. Aquí vive casi todo: agentes, espacios, tickets, memoria, pipelines, reuniones, el grafo de código, revisiones, repos. Los archivos se abren de forma perezosa en el primer acceso mediante WorkspaceDatabaseManager.of(workspaceId) y entonces pagan su propio quick_check, la instalación de FTS/disparadores y vector_init.

Ambas mitades partieron de una base v1 aplanada —onCreate construye el esquema actual—, con pasos de migración añadidos solo cuando una base de datos desplegada tiene que trasladarse hacia delante.

Como un espacio de trabajo es un solo archivo, exportar un espacio de trabajo es una única sentencia VACUUM INTO (workspace.export), no un volcado tabla por tabla; workspace.import adopta un archivo así y usa el workspace_meta incrustado para distinguir su propio archivo readoptado de uno que vino de otra instalación.

El propietario de ambas bases de datos es cc_persistence, y solo las abre cc_server; ningún cliente abre una base de datos: todos los clientes leen y escriben por RPC. Las tablas se definen en packages/cc_persistence/lib/database/tables/, los DAO en .../daos/; las entidades de dominio son Dart puro, separadas de las clases de tabla de Drift, con el mapeo en las capas de datos. FTS5 se encarga de la búsqueda de texto completo y sqlite_vector de los embeddings, ambos solo en el servidor; ambas bases de datos ejecutan PRAGMA foreign_keys=ON en modo de diario WAL.

go_router, con un hecho que gobierna el resto: todo destino dentro de la aplicación lleva el prefijo del espacio de trabajo como /workspaces/:workspaceId/…. El id del espacio de trabajo en la URL es la única fuente de verdad del espacio de trabajo activo —activeWorkspaceIdProvider se deriva de la ruta, y no al revés—, y por eso leer datos con ámbito de espacio de trabajo fuera de una ruta con prefijo no tiene de dónde sacar su id.

  • Funciones constructoras de rutas en router/routes.dart, cada una con el id del espacio de trabajo como primer argumento, no constantes de cadena
  • Cuatro rutas previas al contexto no llevan prefijo: /splash, /onboarding, /signed-out y /workspaces (el selector)
  • ShellRoute envuelve el shell de la aplicación; esas cuatro superficies se representan a pantalla completa fuera de él
  • Guardia de autenticación: una configuración incompleta va a /onboarding; una configuración completada cuya credencial de host de código ha desaparecido va a /signed-out. Completa significa que hay al menos un host de código conectado para el usuario que ha iniciado sesión y que existe al menos un espacio de trabajo —no GitHub en concreto—, y el CLI gh no es un método de autenticación
  • Puerta de onboarding en features/auth/providers/

Todo el HTTP externo vive en el servidor. Los clientes nunca se conectan directamente a GitHub, Linear o Google: llaman a operaciones RPC del servidor, e incluso los medios remotos se obtienen a través del endpoint /proxy/media del servidor en lugar de desde el origen remoto.

  • Clientes dio en packages/cc_infra/lib/src/: GitHubApiClient, GitHubPrClient, GitHubContentClient, GitHubGraphQLClient, GitLabApiClient, BitbucketApiClient, LinearGraphQlClient y GoogleCalendarApiClient
  • Inyección del token de autenticación mediante interceptores, incluido un interceptor OAuth de Google por cuenta en cc_server_core que renueva los tokens al recibir 401
  • Todos los errores se mapean a través de packages/cc_infra/lib/src/network/error_mapper.dart → subclases tipadas de AppException definidas en cc_domain

El servidor guarda las credenciales que importan: los tokens de GitHub y de tickets para los lanzamientos de agentes, las claves de proveedores de LLM y los tokens OAuth viven todos bajo el directorio de datos del servidor o en su entorno. Lo que el llavero del cliente aún guarda es su propia credencial de dispositivo y la clave de emparejamiento de un servidor remoto.

  • Secretos en flutter_secure_storage (llavero de macOS, administrador de credenciales de Windows, libsecret de Linux): la credencial de dispositivo del cliente y las claves de emparejamiento remotas, no los tokens de proveedores
  • shared_preferences solo para preferencias no sensibles (tema, tipografía, disposición)
  • Los secretos de proveedor sobrantes de instalaciones anteriores se entregan al servidor una sola vez mediante features/auth/providers/credential_migration.dart, y después se eliminan del llavero del cliente

Las restricciones de arquitectura se validan con test/core/architecture_constraints_test.dart, que falla si se infringen las reglas de dependencia.