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), et des règles de dépendance strictes sont 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 via l’interface de bouclage — c’est le comportement par 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 même que Riverpod n’existe, puis remplace rpcClientProvider par le client connecté — si bien que chaque fonctionnalité lit et écrit à travers 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 de conserver des espaces de travail actifs différents sans qu’un « espace de travail courant » détenu par le serveur puisse les contredire — et cela signifie que l’isolation est appliquée appel 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 de l’espace de travail. 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 public (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 (base de données 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/ comportent réellement ces quatre répertoires, et les manques sont instructifs plutôt qu’accidentels. 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 ; la plupart des fonctionnalités sous lib/ se composent donc 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 méritent 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 comportent 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 c’est le serveur qui l’applique : une règle associe un ActionClass (ou un commandPrefix de shell) à une décision — allow, prompt ou deny — au sein d’une portée donnée, les règles sont stockées par espace de travail et chaque action d’agent encadrée 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 comportant une règle correspondante décide ; le choix le plus restrictif ne sert que de 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, à l’adresse /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/revue, messagerie, billetterie, pipeline, orchestration, mémoire, calendrier/réunions, identité et appartenance, observabilité)

Control Center est multi-utilisateur : humains et 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 évitent que l’appartenance à un espace de travail n’accorde plus de droits que la forge. Le premier utilisateur créé sur un serveur neuf possède l’installation ; les autres rejoignent par invitation ou par OIDC. Créer un espace de travail inscrit cet utilisateur comme owner.

La collaboration en temps réel repose sur un serveur autoritaire + dernière écriture gagnante 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. Humains et 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 seul.

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 le biais 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 la 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 sous forme de paramètres de constructeur typés, jamais Ref

Drift (SQLite), réparti 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 de 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 d’actualités 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, si bien que le coût de démarrage reste constant, quelle que soit la quantité d’historique accumulée par les espaces de travail. Ajouter une table ici est une décision d’isolation qu’un test à cliquet vous oblige à justifier.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — un répertoire par espace de travail, contenant un fichier SQLite et tout le reste 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, revues, dépôts. Les fichiers s’ouvrent de façon paresseuse au premier accès via 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’un socle v1 consolidé — onCreate construit le schéma actuel — avec des étapes de migration ajoutées uniquement lorsqu’une base de données déployée doit être reprise.

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

Les deux bases de données appartiennent à cc_persistence et ne sont ouvertes que par cc_server ; aucun client n’ouvre de base de données — chaque client lit et é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 vectoriels 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 piloté par 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 ne peut nulle part 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 en dehors
  • 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 initiale dans features/auth/providers/

Tout le HTTP externe réside dans le serveur. Les clients ne contactent 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 via 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 des intercepteurs, dont un intercepteur Google OAuth par compte dans cc_server_core qui rafraîchit les jetons sur une réponse 401
  • Toutes les erreurs passent par packages/cc_infra/lib/src/network/error_mapper.dart → des 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 LLM et les jetons OAuth résident 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.