Salta ai contenuti

Architettura

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.

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.

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.

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 providers

Pochissime 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:

  • mcp ha solo provider. La sua UI di impostazioni e stato vive sotto settings/ e la superficie degli strumenti stessa è un pacchetto (cc_mcp), non una cartella di feature.
  • orchestration e plan_studio portano solo presentation/ e providers/.

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.

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 (sealed UserPrincipal | 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à)

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.

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.

Riverpod per tutto lo stato del client:

  • Notifier<T> e AsyncNotifier<T> per lo stato mutabile
  • FutureProvider<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

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’indice workspace_routes pre-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 tramite WorkspaceDatabaseManager.of(workspaceId) e solo allora pagano il proprio quick_check, l’installazione di FTS/trigger e vector_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.

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-out e /workspaces (il selettore)
  • ShellRoute avvolge 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 CLI gh non è un metodo di autenticazione
  • Gate di onboarding in features/auth/providers/

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, LinearGraphQlClient e GoogleCalendarApiClient
  • Iniezione del token di autenticazione tramite interceptor, incluso un interceptor OAuth di Google per account in cc_server_core che rinnova i token al 401
  • Tutti gli errori vengono mappati tramite packages/cc_infra/lib/src/network/error_mapper.dart → sottoclassi tipizzate di AppException definite in cc_domain

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_preferences solo 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

I vincoli architetturali sono validati da test/core/architecture_constraints_test.dart, che fallisce se le regole di dipendenza vengono violate.