Architettura
Panoramica
Sezione intitolata “Panoramica”Control Center segue la Clean Architecture feature-first con un pattern a porte e adattatori. Il codice è organizzato attorno a domini di business (le feature), con regole di dipendenza rigide fatte rispettare da un test di architettura automatizzato.
Un client leggero su un server headless
Sezione intitolata “Un client leggero su un server headless”Control Center è un’architettura a client leggero. Nessun client apre il database: un processo headless cc_server possiede i dati (un piccolo global.db più un file SQLite per spazio di lavoro, aperti pigramente) e li serve via RPC su WebSocket. L’app desktop, la build web e il compagno per il telefono sono tutti renderer su quell’unica connessione RPC.
Il desktop sceglie come raggiungere il proprio server al primo avvio: locale (avvia un cc_server su questa macchina e comunica su loopback — il valore predefinito) o remoto (chiama un cc_server altrove tramite un WebSocket sicuro). Il client web è sempre remoto. Il risolutore di avvio (bootstrap/server_backend.dart) sceglie il percorso prima che esista Riverpod, poi sovrascrive rpcClientProvider con il client connesso — così ogni feature legge e scrive attraverso il server anziché un database locale.
Il livello RPC stesso è senza stato: una sessione viene autenticata, ma non è vincolata a uno spazio di lavoro. Ogni chiamata con ambito di spazio di lavoro porta il proprio workspace_id nei suoi argomenti e il dispatcher rifiuta una chiamata di questo tipo se non lo contiene. È questo che consente a due client su un solo server di mantenere spazi di lavoro attivi diversi senza un «spazio di lavoro corrente» detenuto dal server su cui possano discordare — e significa che l’isolamento viene applicato per chiamata, non per connessione.
Questo è un unico workspace Dart con lockfile risolto di ventuno membri — quindici pacchetti più sei applicazioni — con l’app desktop+web (control_center) come radice del workspace. Le sei applicazioni sono il binario del server headless (cc_server), l’esecutore headless della flotta (cc_worker), la PWA del telefono (cc_remote), il broker di relay WebSocket (cc_signaling_server), la galleria Widgetbook (cc_gallery) e l’host della demo pubblica (cc_demo_server). I quindici pacchetti sono cc_ui (design system), cc_domain (kernel condiviso in Dart puro: entità, value object, porte, eventi e il livello di dominio di ogni feature), cc_harness (il kernel integrato degli agenti: un loop di agente in Dart puro — messaggi, porta del provider, strumenti, compattazione, steering, hook, subagent — senza dart:io e senza altre dipendenze cc_*), cc_harness_runtime (l’adattatore runtime del harness integrato: provider di streaming solo per la VM, intermediazione delle credenziali OAuth/PKCE, archivi di credenziali e il set generico di strumenti — esegue agenti senza alcuna CLI esterna installata), cc_rpc (trasporti del client), cc_host (kernel del server), cc_data (repository remoti), cc_persistence (DB lato server), cc_server_core (composizione app-server + runtime di identità/presenza/flotta/eval), cc_infra (adattatori solo per la VM), cc_mcp (strumenti MCP), cc_mcp_client (client per server MCP esterni, che collega i loro strumenti al registro locale), cc_markdown (motore markdown interno al repo), cc_natives (foglia FFI) e system_audio_capture. Consulta Deployment e client per la matrice completa dei client e cosa gira dove.
Regola delle dipendenze
Sezione intitolata “Regola delle dipendenze”Presentation → Application/Providers → Domain ← Infrastructure- Livello di dominio: entità, value object, interfacce dei repository, porte e servizi di dominio in Dart puro. Zero import infrastrutturali (niente dio, drift o modelli di rete).
- Livello di presentazione: schermate, widget, notificatori. Nessun accesso diretto a drift/DAO/livello dati; tutto passa attraverso i provider Riverpod → repository.
- Livello di infrastruttura: implementazioni concrete delle porte di dominio e delle interfacce dei repository. Dipende dal dominio, mai il contrario.
Struttura delle feature
Sezione intitolata “Struttura delle feature”La forma completa di una feature è:
feature_name/├── data/ # Repository implementations, data sources, services, DTOs, mappers├── domain/ # Entities, repository interfaces, ports, use cases├── presentation/ # Screens, widgets, notifiers└── providers/ # Riverpod providersPochissime feature sotto lib/features/ presentano davvero tutte e quattro, e queste lacune sono informative anziché accidentali. Il kernel condiviso ha assorbito il domain/ di ogni feature in cc_domain e il client leggero non contiene implementazioni di repository, quindi la maggior parte delle feature sotto lib/ è composta da presentation/ più providers/ e nulla più. Una directory data/ o domain/ che sopravvive nel client segnala qualcosa di genuinamente lato client.
Ci sono due eccezioni con nome proprio che vale la pena conoscere:
mcpha solo provider. La sua UI di impostazioni e stato vive sottosettings/e la superficie degli strumenti stessa è un pacchetto (cc_mcp), non una cartella di feature.orchestrationeplan_studioportano solopresentation/eproviders/.
La feature guardrails colloca il proprio livello di dominio in cc_domain/features/guardrails/ invece che nell’app root, perché è il server ad applicarla: una regola mappa un ActionClass (o un commandPrefix di shell) su una decisione — allow, prompt o deny — in un ambito; le regole sono archiviate per spazio di lavoro e ogni azione di agente governata viene risolta attraverso la catena di policy prima di essere eseguita. La risoluzione percorre space > agent > workspace > mode preset > built-in default e decide il primo ambito con una regola corrispondente; il più restrittivo è solo uno spareggio all’interno di un ambito e per combinare le diverse classi che un’azione dichiara. La superficie rivolta all’operatore è Impostazioni → Spazio di lavoro → Autorizzazioni degli agenti, in /workspaces/<id>/settings/workspace/permissions. Consulta Guardrail per il modello.
Kernel condiviso
Sezione intitolata “Kernel condiviso”packages/cc_domain/lib/core/domain/ contiene entità e repository condivisi da 3 o più feature. È Dart puro con zero dipendenze infrastrutturali, ed è ciò che permette sia al client Flutter sia al binario del server senza Flutter di importare le stesse definizioni:
- Entità principali:
Agent,AgentRunLog,Workspace,Repo,ReviewSpaceAssociation - Identità e multigiocatore:
User,Principal(sealedUserPrincipal|AgentPrincipal),WorkspaceMember,WorkspaceRole - Entità di memoria:
MemoryFact,MemoryPolicy,AgentWorkingMemory,MemoryAccessGrant - Value object condivisi:
AgentSkills,AgentRole,Mode,SandboxBackend,SandboxSpec,RunCost - Porte condivise:
SandboxPort,CredentialBrokerPort,WorkspaceFilesystemPort,GitRepoInspectorPort,EmbeddingPort - Servizi di dominio:
MemoryAccessPolicy,ActivityLogger,MentionResolver,RunLivenessClassifier DomainEventBus+ tipi di evento (spazio di lavoro/agente, PR/revisione, messaggistica, ticketing, pipeline, orchestrazione, memoria, calendario/riunioni, identità e appartenenza, osservabilità)
Identità e multigiocatore
Sezione intitolata “Identità e multigiocatore”Control Center è multiutente: persone e agenti sono attori alla pari unificati da un Principal. Un membro di uno spazio di lavoro è un User vincolato a un Workspace con un WorkspaceRole (owner/admin/member/viewer/guest). L’appartenenza è il confine di accesso — non una chiave di abbinamento — e le concessioni per repo impediscono che l’appartenenza a uno spazio di lavoro conferisca più privilegi dell’host di codice. Il primo utente creato su un server nuovo possiede l’installazione; gli altri si uniscono tramite invito o OIDC. Creare uno spazio di lavoro contrassegna quell’utente come owner.
La collaborazione in tempo reale è server autoritativo + last-writer-wins (LWW) per campo, non un CRDT. La presenza (chi c’è, dove si trova, cursori, digitazione, lo stato in tempo reale di un agente e il costo di esecuzione) è una corsia effimera separata che non viene mai persistita; lo stato durevole viaggia su una dorsale di mutazione ottimistica + rebase sul server + LWW. Persone e agenti condividono un unico elenco. Con un solo operatore umano la corsia della presenza resta inattiva e non compare alcun elemento di interfaccia dell’elenco — il multigiocatore non costa nulla all’utente in solitaria.
Porte e adattatori
Sezione intitolata “Porte e adattatori”Le porte sono interfacce astratte nel livello di dominio. Gli adattatori sono implementazioni concrete nell’infrastruttura. Poiché il server headless possiede i dati e deve compilare senza Flutter, quasi tutti gli adattatori sono solo per la VM e vivono in packages/cc_infra/lib/src/, organizzati per ambito:
| Porta | Posizione dell’adattatore |
|---|---|
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/ (lato client: visualizza localmente gli eventi del server) |
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 sono adattatori di sincronizzazione dei ticket in cc_infra/lib/src/tickets/sync/; i ticket locali vivono nel database dello spazio di lavoro senza alcun TicketProviderPort. |
PipelineEnginePort |
cc_domain features/pipelines/domain/services/ (un servizio di dominio in Dart puro: non richiede infrastruttura) |
La radice di composizione dell’app (di/providers.dart) collega le porte lato client alle implementazioni tramite provider Riverpod; il server compone i propri adattatori in cc_server_core.
Gestione dello stato
Sezione intitolata “Gestione dello stato”Riverpod per tutto lo stato del client:
Notifier<T>eAsyncNotifier<T>per lo stato mutabileFutureProvider<T>per i dati asincroni- Gli elenchi e le righe in tempo reale arrivano tramite sottoscrizioni RPC come
AsyncValue<List<T>>— il server usa Drift.watch()internamente; nessun client apre Drift - Gli strumenti MCP ricevono le dipendenze come parametri di costruttore tipizzati, mai come
Ref
Database
Sezione intitolata “Database”Drift (SQLite), suddiviso in due database, entrambi in cc_persistence:
GlobalDatabase(<dataDir>/global.db) — un breve elenco di tabelle realmente a livello di server: il registro degli spazi di lavoro, utenti/preferenze/dispositivi associati, connessioni SSO, impostazioni del server a livello di installazione, policy delle azioni gestite (un limite a livello di installazione che può solo stringere i guardrail degli spazi di lavoro), il feed di notizie per utente, la coda della flotta (worker/job/registro di collocazione), l’indiceworkspace_routespre-autenticazione e i metadati dell’installazione. L’avvio apre solo questo file, quindi il costo di avvio resta costante indipendentemente da quanta cronologia accumulano gli spazi di lavoro. Aggiungere una tabella qui è una decisione di isolamento che un test ratchet ti obbliga a motivare.WorkspaceDatabase(<dataDir>/<workspaceId>/workspace.db) — una directory per spazio di lavoro, che contiene un file SQLite più tutto il resto che appartiene solo a quello spazio di lavoro, così i worktree e i file di agente di una conversazione stanno accanto al database e vengono eliminati con esso. Quasi tutto vive qui: agenti, spazi, ticket, memoria, pipeline, riunioni, il grafo del codice, revisioni, repo. I file si aprono pigramente al primo accesso tramiteWorkspaceDatabaseManager.of(workspaceId)e solo allora pagano il proprioquick_check, l’installazione di FTS/trigger evector_init.
Entrambe le metà sono partite da una baseline v1 schiacciata — onCreate costruisce lo schema attuale — con passaggi di migrazione aggiunti solo quando un database distribuito deve essere portato avanti.
Poiché uno spazio di lavoro è un unico file, esportare uno spazio di lavoro è una singola istruzione VACUUM INTO (workspace.export), non un dump tabella per tabella; workspace.import adotta un file di questo tipo e usa il workspace_meta integrato per distinguere il proprio file riadottato da uno provenuto da un’altra installazione.
Entrambi i database sono di proprietà di cc_persistence e vengono aperti solo da cc_server; nessun client apre un DB — ogni client legge e scrive via RPC. Le tabelle sono definite in packages/cc_persistence/lib/database/tables/, i DAO in .../daos/; le entità di dominio sono Dart puro, separate dalle classi di tabella Drift, con il mapping nei livelli dati. FTS5 gestisce la ricerca full-text e sqlite_vector gli embedding, entrambi solo lato server; entrambi i database eseguono PRAGMA foreign_keys=ON in modalità journal WAL.
Routing
Sezione intitolata “Routing”go_router, con un fatto che governa tutto il resto: ogni destinazione all’interno dell’app ha il prefisso dello spazio di lavoro nella forma /workspaces/:workspaceId/…. L’id dello spazio di lavoro nell’URL è l’unica fonte di verità per lo spazio di lavoro attivo — activeWorkspaceIdProvider è guidato dalla route, non il contrario — ed è per questo che leggere dati con ambito di spazio di lavoro fuori da una route con prefisso non ha da nessuna parte da cui ricavare il proprio id.
- Funzioni di costruzione delle route in
router/routes.dart, ognuna con l’id dello spazio di lavoro come primo argomento, non costanti stringa - Quattro route precedenti al contesto non hanno prefisso:
/splash,/onboarding,/signed-oute/workspaces(il selettore) ShellRouteavvolge lo shell dell’app; quelle quattro superfici vengono mostrate a schermo intero al di fuori di esso- Guardia di autenticazione: una configurazione incompleta va a
/onboarding; una configurazione completata la cui credenziale dell’host di codice è sparita va a/signed-out. Completa significa che per l’utente che ha effettuato l’accesso è collegato almeno un host di codice ed esiste almeno uno spazio di lavoro — non GitHub nello specifico — e la CLIghnon è un metodo di autenticazione - Gate di onboarding in
features/auth/providers/
Rete (lato server)
Sezione intitolata “Rete (lato server)”Tutto l’HTTP esterno vive nel server. I client non contattano mai direttamente GitHub, Linear o Google: chiamano operazioni RPC del server e persino i media remoti vengono recuperati attraverso l’endpoint /proxy/media del server anziché dall’origine a monte.
- Client dio in
packages/cc_infra/lib/src/:GitHubApiClient,GitHubPrClient,GitHubContentClient,GitHubGraphQLClient,GitLabApiClient,BitbucketApiClient,LinearGraphQlClienteGoogleCalendarApiClient - Iniezione del token di autenticazione tramite interceptor, incluso un interceptor OAuth di Google per account in
cc_server_coreche rinnova i token al 401 - Tutti gli errori vengono mappati tramite
packages/cc_infra/lib/src/network/error_mapper.dart→ sottoclassi tipizzate diAppExceptiondefinite incc_domain
Sicurezza
Sezione intitolata “Sicurezza”Il server custodisce le credenziali che contano: i token di GitHub e dei ticket per i lanci degli agenti, le chiavi dei provider LLM e i token OAuth vivono tutti sotto la directory dati del server o nel suo ambiente. Ciò che il portachiavi del client conserva ancora è la propria credenziale di dispositivo e la chiave di abbinamento per un server remoto.
- Segreti in
flutter_secure_storage(portachiavi di macOS, gestione credenziali di Windows, libsecret di Linux) — la credenziale di dispositivo del client e le chiavi di abbinamento remote, non i token dei provider shared_preferencessolo per preferenze non sensibili (tema, font, layout)- I segreti dei provider rimasti da installazioni precedenti vengono consegnati al server una volta da
features/auth/providers/credential_migration.dart, poi eliminati dal portachiavi del client
Applicazione dell’architettura
Sezione intitolata “Applicazione dell’architettura”I vincoli architetturali sono validati da test/core/architecture_constraints_test.dart, che fallisce se le regole di dipendenza vengono violate.
Concetti correlati
Sezione intitolata “Concetti correlati”- Spazi di lavoro e isolamento: come viene applicato l’isolamento
- Ciclo di vita dell’invio degli agenti: il flusso di invio
- Eventi di dominio: la comunicazione tra feature
- Deployment e client: il modello a client leggero con server e la matrice dei client