Aller au contenu

Architecture

Control Center suit une Clean Architecture « feature-first » avec un motif ports et adaptateurs. La base de code est organisée autour de domaines métier (les fonctionnalités), avec des règles de dépendance strictes appliquées par un test d’architecture automatisé.

Un client léger au-dessus d’un serveur sans interface

Section intitulée “Un client léger au-dessus d’un serveur sans interface”

Control Center repose sur une architecture de client léger. Aucun client n’ouvre la base de données — un processus cc_server sans interface détient les données (un petit global.db plus un fichier SQLite par espace de travail, ouverts de façon paresseuse) et les sert par RPC WebSocket. L’application de bureau, la version web et l’application compagnon pour téléphone ne sont que des interfaces de rendu au-dessus de cette unique connexion RPC.

Au premier lancement, l’application de bureau choisit comment joindre son serveur : local (elle lance un cc_server sur cette machine et communique par bouclage — c’est le défaut) ou distant (elle joint un cc_server ailleurs par WebSocket sécurisé). Le client web est toujours distant. Le résolveur d’amorçage (bootstrap/server_backend.dart) choisit le chemin avant que Riverpod n’existe, puis remplace rpcClientProvider par le client connecté — ainsi chaque fonctionnalité lit et écrit par le serveur plutôt que dans une base de données locale.

La couche RPC elle-même est sans état : une session est authentifiée, mais elle n’est liée à aucun espace de travail. Chaque appel propre à un espace de travail transmet son propre workspace_id dans ses arguments, et le répartiteur refuse un tel appel sans cet identifiant. C’est ce qui permet à deux clients d’un même serveur d’avoir des espaces de travail actifs différents sans qu’un « espace de travail courant » détenu par le serveur ne puisse les contredire — et cela signifie que l’isolation est appliquée par appel, et non par connexion.

Il s’agit d’un espace de travail Dart à fichier de verrouillage résolu unique, comptant vingt et un membres — quinze paquets plus six applications — avec l’application bureau+web (control_center) comme racine. Les six applications sont le binaire de serveur sans interface (cc_server), l’exécuteur de flotte sans interface (cc_worker), la PWA pour téléphone (cc_remote), le courtier relais WebSocket (cc_signaling_server), la galerie Widgetbook (cc_gallery) et l’hôte de démonstration publique (cc_demo_server). Les quinze paquets sont cc_ui (système de design), cc_domain (noyau partagé en Dart pur : entités, objets valeur, ports, événements et la couche domaine de chaque fonctionnalité), cc_harness (le noyau d’agent intégré : une boucle d’agent en Dart pur — messages, port de fournisseur, outils, compaction, pilotage, crochets, sous-agents — sans dart:io ni aucune autre dépendance cc_*), cc_harness_runtime (l’adaptateur d’exécution du noyau intégré : fournisseurs de flux réservés à la VM, courtage d’identifiants OAuth/PKCE, magasins d’identifiants et ensemble d’outils génériques — exécute des agents sans aucune interface de ligne de commande externe installée), cc_rpc (transports clients), cc_host (noyau du serveur), cc_data (dépôts distants), cc_persistence (BD côté serveur), cc_server_core (composition application-serveur + exécution identité/présence/flotte/évaluations), cc_infra (adaptateurs réservés à la VM), cc_mcp (outils MCP), cc_mcp_client (client pour les serveurs MCP externes, qui intègre leurs outils au registre local), cc_markdown (moteur Markdown interne au dépôt), cc_natives (feuille FFI) et system_audio_capture. Voir Déploiement et clients pour la matrice complète des clients et ce qui s’exécute où.

Presentation → Application/Providers → Domain ← Infrastructure
  • Couche domaine : entités, objets valeur, interfaces de dépôt, ports et services de domaine en Dart pur. Aucun import d’infrastructure (ni dio, ni drift, ni modèles réseau).
  • Couche présentation : écrans, widgets, notificateurs. Aucun accès direct à drift/DAO/couche de données ; tout passe par les fournisseurs Riverpod → dépôts.
  • Couche infrastructure : implémentations concrètes des ports et des interfaces de dépôt du domaine. Dépend du domaine, jamais l’inverse.

La forme complète d’une fonctionnalité est la suivante :

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

Très peu de fonctionnalités sous lib/features/ portent réellement les quatre, et ces lacunes sont informatives plutôt qu’accidentelles. Le noyau partagé a absorbé le domain/ de chaque fonctionnalité dans cc_domain, et le client léger ne contient aucune implémentation de dépôt, de sorte que la plupart des fonctionnalités sous lib/ se composent de presentation/ et de providers/, rien de plus. Un répertoire data/ ou domain/ qui subsiste dans le client signale quelque chose de réellement côté client.

Deux exceptions nommées valent la peine d’être connues :

  • mcp ne contient que des fournisseurs. Son interface de paramètres et d’état vit sous settings/, et la surface d’outils elle-même est un paquet (cc_mcp), pas un dossier de fonctionnalité.
  • orchestration et plan_studio ne portent que presentation/ et providers/.

La fonctionnalité guardrails place sa couche domaine dans cc_domain/features/guardrails/ plutôt que dans l’application racine, parce que le serveur l’applique : une règle associe un ActionClass (ou un commandPrefix de shell) à une décision — allow, prompt ou deny — à une portée donnée, les règles sont stockées par espace de travail et chaque action d’agent régie passe par la chaîne de politiques avant de s’exécuter. La résolution parcourt space > agent > workspace > mode preset > built-in default et la première portée ayant une règle correspondante décide ; la plus restrictive n’est qu’un départage au sein d’une même portée et pour combiner les différentes classes que déclare une action. La surface destinée à l’opérateur est Paramètres → Espace de travail → Autorisations des agents, à /workspaces/<id>/settings/workspace/permissions. Voir Garde-fous pour le modèle.

packages/cc_domain/lib/core/domain/ contient les entités et les dépôts partagés par 3 fonctionnalités ou plus. C’est du Dart pur sans aucune dépendance d’infrastructure, ce qui permet au client Flutter comme au binaire serveur sans Flutter d’importer les mêmes définitions :

  • Entités de base : Agent, AgentRunLog, Workspace, Repo, ReviewSpaceAssociation
  • Identité et multijoueur : User, Principal (scellé UserPrincipal | AgentPrincipal), WorkspaceMember, WorkspaceRole
  • Entités de mémoire : MemoryFact, MemoryPolicy, AgentWorkingMemory, MemoryAccessGrant
  • Objets valeur partagés : AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • Ports partagés : SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • Services de domaine : MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + types d’événements (espace de travail/agent, PR/révision, messagerie, billetterie, pipeline, orchestration, mémoire, calendrier/réunions, identité et appartenance, observabilité)

Control Center est multijoueur : les humains et les agents sont des acteurs sur un pied d’égalité, unifiés par un Principal. Un membre d’un espace de travail est un User lié à un Workspace selon un WorkspaceRole (owner/admin/member/viewer/guest). L’appartenance constitue la frontière d’accès — et non une clé d’appairage — et les autorisations par dépôt empêchent l’appartenance à un espace de travail d’octroyer plus de droits que la forge. Le premier utilisateur créé sur un serveur neuf possède l’installation ; les autres se joignent par invitation ou par OIDC. La création d’un espace de travail inscrit cet utilisateur comme owner.

La collaboration en temps réel repose sur un serveur autoritaire + dernier écrivain gagne par champ (LWW), et non sur un CRDT. La présence (qui est là, où chacun se trouve, les curseurs, la saisie en cours, l’état en direct d’un agent et son coût en cours) forme une voie éphémère distincte qui n’est jamais conservée ; l’état durable repose sur une colonne vertébrale mutation optimiste + rebasage serveur + LWW. Les humains et les agents partagent une même liste. Avec un seul opérateur humain, la voie de présence reste inactive et aucun élément d’interface de liste n’apparaît — le multijoueur ne coûte rien à l’utilisateur solo.

Les ports sont des interfaces abstraites de la couche domaine. Les adaptateurs sont des implémentations concrètes dans l’infrastructure. Comme le serveur sans interface détient les données et doit se compiler sans Flutter, presque tous les adaptateurs sont réservés à la VM et vivent dans packages/cc_infra/lib/src/, organisés par préoccupation :

Port Emplacement de l’adaptateur
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/ (côté client : affiche localement les événements du serveur)
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 et GitHub Issues sont des adaptateurs de synchronisation de tickets dans cc_infra/lib/src/tickets/sync/ ; les tickets locaux vivent dans la base de données de l’espace de travail, sans TicketProviderPort.
PipelineEnginePort cc_domain features/pipelines/domain/services/ (un service de domaine en Dart pur — il n’a besoin d’aucune infrastructure)

La racine de composition de l’application (di/providers.dart) lie les ports côté client à des implémentations par l’entremise de fournisseurs Riverpod ; le serveur compose ses adaptateurs dans cc_server_core.

Riverpod pour tout l’état client :

  • Notifier<T> et AsyncNotifier<T> pour l’état mutable
  • FutureProvider<T> pour les données asynchrones
  • Les listes et les lignes en direct arrivent par abonnements RPC sous forme d’AsyncValue<List<T>> — le serveur utilise Drift .watch() en interne ; aucun client n’ouvre Drift
  • Les outils MCP reçoivent leurs dépendances comme paramètres de constructeur typés, jamais Ref

Drift (SQLite), répartie en deux bases de données, toutes deux dans cc_persistence :

  • GlobalDatabase (<dataDir>/global.db) — une courte liste de tables réellement communes à tout le serveur : le registre des espaces de travail, les utilisateurs/préférences/appareils appairés, les connexions SSO, les paramètres du serveur à l’échelle de l’installation, les politiques d’actions gérées (un verrou à l’échelle de l’installation qui ne peut que resserrer les garde-fous des espaces de travail), le fil de nouvelles par utilisateur, la file de la flotte (workers/tâches/journal de placement), l’index workspace_routes avant authentification et les métadonnées de l’installation. Le démarrage n’ouvre que ce fichier, de sorte que le coût de démarrage reste constant, peu importe l’historique accumulé par les espaces de travail. Ajouter une table ici est une décision d’isolation qu’un test à cliquet vous force à justifier.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — un répertoire par espace de travail, contenant un fichier SQLite plus tout ce qui n’appartient qu’à cet espace de travail, de sorte que les arbres de travail et les fichiers d’agent d’une conversation se trouvent à côté de la base de données et sont supprimés avec elle. Presque tout vit ici : agents, espaces, tickets, mémoire, pipelines, réunions, graphe de code, révisions, dépôts. Les fichiers s’ouvrent de façon paresseuse au premier accès par WorkspaceDatabaseManager.of(workspaceId) et paient alors leur propre quick_check, l’installation FTS/déclencheurs et vector_init.

Les deux moitiés sont parties d’une base de référence v1 consolidée — onCreate construit le schéma actuel — avec des étapes de migration ajoutées seulement lorsqu’une base déployée doit être reprise.

Comme un espace de travail correspond à un fichier, exporter un espace de travail est une simple instruction VACUUM INTO (workspace.export), et non un export table par table ; workspace.import adopte un tel fichier et se sert du workspace_meta intégré pour distinguer son propre fichier réadopté d’un fichier provenant d’une autre installation.

Les deux bases de données appartiennent à cc_persistence et ne sont ouvertes que par cc_server ; aucun client n’ouvre de BD — chaque client lit/écrit par RPC. Les tables sont définies dans packages/cc_persistence/lib/database/tables/, les DAO dans .../daos/ ; les entités du domaine sont en Dart pur, distinctes des classes de tables Drift, avec le mappage dans les couches de données. FTS5 assure la recherche plein texte et les plongements sqlite_vector, tous deux réservés au serveur ; les deux bases s’exécutent avec PRAGMA foreign_keys=ON en mode journal WAL.

go_router, avec un fait qui régit tout le reste : chaque destination dans l’application est préfixée par l’espace de travail sous la forme /workspaces/:workspaceId/…. L’identifiant d’espace de travail présent dans l’URL est la source unique de vérité de l’espace de travail actif — activeWorkspaceIdProvider est dérivé de la route, et non l’inverse —, ce qui explique pourquoi la lecture de données propres à un espace de travail en dehors d’une route préfixée n’a nulle part où trouver son identifiant.

  • Des fonctions de construction de routes dans router/routes.dart, chacune prenant l’identifiant d’espace de travail comme premier argument, et non des constantes de chaîne
  • Quatre routes hors contexte ne sont pas préfixées : /splash, /onboarding, /signed-out et /workspaces (le sélecteur)
  • ShellRoute enveloppe la coquille de l’application ; ces quatre surfaces s’affichent en plein écran à l’extérieur
  • Garde d’authentification : une configuration incomplète mène à /onboarding ; une configuration complète dont l’identifiant de forge a disparu mène à /signed-out. Complète signifie qu’au moins une forge est connectée pour l’utilisateur connecté et qu’au moins un espace de travail existe — pas GitHub en particulier — et l’interface de ligne de commande gh n’est pas une méthode d’authentification
  • Filtre d’intégration dans features/auth/providers/

Tout le HTTP externe vit dans le serveur. Les clients ne joignent jamais GitHub, Linear ou Google directement — ils appellent des opérations RPC du serveur, et même les médias distants sont récupérés par le point de terminaison /proxy/media du serveur plutôt qu’à partir de l’origine en amont.

  • Clients dio dans packages/cc_infra/lib/src/ : GitHubApiClient, GitHubPrClient, GitHubContentClient, GitHubGraphQLClient, GitLabApiClient, BitbucketApiClient, LinearGraphQlClient et GoogleCalendarApiClient
  • Injection des jetons d’authentification par intercepteurs, y compris un intercepteur Google OAuth par compte dans cc_server_core qui rafraîchit les jetons sur une réponse 401
  • Toutes les erreurs sont mappées par packages/cc_infra/lib/src/network/error_mapper.dart → sous-classes typées d’AppException définies dans cc_domain

Le serveur détient les identifiants qui comptent : les jetons GitHub et de billetterie pour les lancements d’agents, les clés de fournisseur de LLM et les jetons OAuth vivent tous sous le répertoire de données du serveur ou dans son environnement. Ce que le trousseau du client conserve encore, c’est son propre identifiant d’appareil et la clé d’appairage d’un serveur distant.

  • Secrets dans flutter_secure_storage (trousseau macOS, gestionnaire d’identifiants Windows, libsecret Linux) — l’identifiant d’appareil du client et les clés d’appairage distantes, et non les jetons de fournisseur
  • shared_preferences uniquement pour les préférences non sensibles (thème, police, disposition)
  • Les secrets de fournisseur résiduels d’anciennes installations sont remis une seule fois au serveur par features/auth/providers/credential_migration.dart, puis supprimés du trousseau du client

Les contraintes d’architecture sont validées par test/core/architecture_constraints_test.dart, qui échoue si les règles de dépendance sont violées.