Sari la conținut

Arhitectură

Control Center urmează Clean Architecture orientată pe funcționalități, cu modelul porturi și adaptoare. Baza de cod este organizată în jurul domeniilor de business (funcționalități), cu reguli stricte de dependență impuse de un test automat de arhitectură.

Control Center este o arhitectură cu client subțire. Niciun client nu deschide baza de date — un proces headless cc_server deține datele (un global.db mic plus un fișier SQLite pentru fiecare spațiu de lucru, deschis la cerere) și le servește prin RPC peste WebSocket. Aplicația desktop, versiunea web și companionul de telefon sunt toate simple interfețe de randare peste acea unică conexiune RPC.

Desktopul alege cum ajunge la serverul său la prima lansare: local (pornește un cc_server pe această mașină și comunică prin loopback — varianta implicită) sau remote (se conectează la un cc_server aflat în altă parte, printr-un WebSocket securizat). Clientul web este întotdeauna remote. Rezolvatorul de pornire (bootstrap/server_backend.dart) alege calea înainte ca Riverpod să existe, apoi suprascrie rpcClientProvider cu clientul conectat — astfel încât fiecare funcționalitate citește și scrie prin server, nu printr-o bază de date locală.

Stratul RPC în sine este fără stare: o sesiune este autentificată, dar nu este legată de un spațiu de lucru. Fiecare apel care ține de un spațiu de lucru își cară propriul workspace_id în argumente, iar dispecerul refuză un astfel de apel fără el. Asta permite ca doi clienți de pe același server să aibă spații de lucru active diferite fără un „spațiu de lucru curent” ținut de server cu care să intre în contradicție — și înseamnă că izolarea este impusă la fiecare apel, nu la fiecare conexiune.

Acesta este un singur spațiu de lucru Dart cu lockfile rezolvat, de douăzeci și unu de membri — cincisprezece pachete plus șase aplicații — cu aplicația desktop+web (control_center) ca rădăcină a spațiului de lucru. Cele șase aplicații sunt binarul de server headless (cc_server), executantul headless de flotă (cc_worker), PWA-ul de telefon (cc_remote), brokerul releu WebSocket (cc_signaling_server), galeria Widgetbook (cc_gallery) și gazda publică de demo (cc_demo_server). Cele cincisprezece pachete sunt cc_ui (sistem de design), cc_domain (kernel partajat în Dart pur: entități, obiecte-valoare, porturi, evenimente și stratul de domeniu al fiecărei funcționalități), cc_harness (kernelul de agent integrat: o buclă de agent în Dart pur — mesaje, port de furnizor, instrumente, compactare, direcționare, hook-uri, subagenți — fără dart:io și fără nicio altă dependență cc_*), cc_harness_runtime (adaptorul de runtime al harness-ului integrat: furnizori de streaming doar pentru VM, brokeraj de acreditări OAuth/PKCE, depozite de acreditări și setul generic de instrumente — rulează agenți fără niciun CLI extern instalat), cc_rpc (transporturi de client), cc_host (kernel de server), cc_data (repository-uri remote), cc_persistence (bază de date pe server), cc_server_core (compoziție aplicație-server + runtime de identitate/prezență/flotă/eval-uri), cc_infra (adaptoare doar pentru VM), cc_mcp (instrumente MCP), cc_mcp_client (client pentru servere MCP externe, care le aduce instrumentele în registrul local), cc_markdown (motor markdown din depozit), cc_natives (nod frunză FFI) și system_audio_capture. Vezi Implementare și clienți pentru matricea completă de clienți și ce rulează unde.

Presentation → Application/Providers → Domain ← Infrastructure
  • Stratul de domeniu: entități, obiecte-valoare, interfețe de repository, porturi și servicii de domeniu în Dart pur. Zero importuri de infrastructură (fără dio, drift sau modele de rețea).
  • Stratul de prezentare: ecrane, widgeturi, notifiers. Fără acces direct la drift/DAO/stratul de date; totul trece prin furnizorii Riverpod → repository-uri.
  • Stratul de infrastructură: implementări concrete ale porturilor de domeniu și ale interfețelor de repository. Depinde de domeniu, niciodată invers.

Structura completă a unei funcționalități este:

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

Foarte puține funcționalități din lib/features/ chiar au toate cele patru, iar lipsurile sunt informative, nu accidentale. Kernelul partajat a absorbit domain/ al fiecărei funcționalități în cc_domain, iar clientul subțire nu ține nicio implementare de repository, așa că majoritatea funcționalităților din lib/ sunt presentation/ plus providers/ și nimic altceva. Un director data/ sau domain/ care supraviețuiește în client marchează ceva cu adevărat de partea clientului.

Merită cunoscute două excepții numite:

  • mcp conține doar providers. Interfața sa de setări și de stare se află sub settings/, iar suprafața de instrumente în sine este un pachet (cc_mcp), nu un director de funcționalitate.
  • orchestration și plan_studio conțin doar presentation/ și providers/.

Funcționalitatea guardrails își pune stratul de domeniu în cc_domain/features/guardrails/, nu în aplicația rădăcină, pentru că serverul o impune: o regulă asociază un ActionClass (sau un commandPrefix de shell) unei decizii — allow, prompt sau deny — la un domeniu de aplicare, regulile sunt stocate pentru fiecare spațiu de lucru, iar fiecare acțiune guvernată a unui agent se rezolvă prin lanțul de politici înainte de a rula. Rezolvarea parcurge space > agent > workspace > mode preset > built-in default și primul domeniu de aplicare cu o regulă potrivită decide; varianta cea mai restrictivă este doar un criteriu de departajare în interiorul unui singur domeniu de aplicare și la combinarea mai multor clase declarate de o acțiune. Suprafața pentru operator este Setări → Spațiu de lucru → Permisiuni agent, la /workspaces/<id>/settings/workspace/permissions. Vezi Guardrail-uri pentru model.

packages/cc_domain/lib/core/domain/ conține entități și repository-uri partajate de 3 sau mai multe funcționalități. Este Dart pur, cu zero dependențe de infrastructură, ceea ce permite atât clientului Flutter, cât și binarului de server fără Flutter să importe aceleași definiții:

  • Entități de bază: Agent, AgentRunLog, Workspace, Repo, ReviewSpaceAssociation
  • Identitate și multiplayer: User, Principal (sealed UserPrincipal | AgentPrincipal), WorkspaceMember, WorkspaceRole
  • Entități de memorie: MemoryFact, MemoryPolicy, AgentWorkingMemory, MemoryAccessGrant
  • Obiecte-valoare partajate: AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • Porturi partajate: SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • Servicii de domeniu: MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + tipuri de evenimente (spațiu de lucru/agent, PR/revizuire, mesagerie, tichete, pipeline, orchestrare, memorie, calendar/întâlniri, identitate și apartenență, observabilitate)

Control Center este multi-utilizator: oamenii și agenții sunt actori de rang egal, unificați de un Principal. Un membru al unui spațiu de lucru este un User legat de un Workspace printr-un WorkspaceRole (owner/admin/member/viewer/guest). Apartenența este granița de acces — nu o cheie de asociere — iar acordurile per repo împiedică apartenența la spațiul de lucru să acorde mai multe privilegii decât forge-ul. Primul utilizator creat pe un server nou deține instalarea; ceilalți se alătură prin invitație sau OIDC. Crearea unui spațiu de lucru îl marchează pe acel utilizator ca owner.

Colaborarea în timp real folosește modelul server autoritativ + last-writer-wins (LWW) pe fiecare câmp, nu un CRDT. Prezența (cine e aici, unde se află, cursoare, tastare, starea live a unui agent și costul rulării) este o cale efemeră separată, care nu este niciodată persistată; starea durabilă se bazează pe o coloană vertebrală de mutații optimiste + rebazare pe server + LWW. Oamenii și agenții împart aceeași listă de participanți. Cu un singur operator uman, calea de prezență rămâne inactivă și nu apare nicio interfață de listă — multiplayer nu costă nimic utilizatorul care lucrează singur.

Porturile sunt interfețe abstracte din stratul de domeniu. Adaptoarele sunt implementări concrete în infrastructură. Deoarece serverul headless deține datele și trebuie să se compileze fără Flutter, aproape fiecare adaptor este doar pentru VM și se află în packages/cc_infra/lib/src/, organizat pe preocupări:

Port Locația adaptorului
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/ (de partea clientului: randează local evenimentele serverului)
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 și GitHub Issues sunt adaptoare de sincronizare a tichetelor în cc_infra/lib/src/tickets/sync/; tichetele locale se află în baza de date a spațiului de lucru, fără TicketProviderPort.
PipelineEnginePort cc_domain features/pipelines/domain/services/ (un serviciu de domeniu în Dart pur — nu are nevoie de infrastructură)

Rădăcina de compoziție a aplicației (di/providers.dart) leagă porturile de pe client de implementări prin furnizorii Riverpod; serverul își compune adaptoarele în cc_server_core.

Riverpod pentru toată starea clientului:

  • Notifier<T> și AsyncNotifier<T> pentru starea mutabilă
  • FutureProvider<T> pentru date asincrone
  • Listele și rândurile live sosesc prin abonamente RPC ca AsyncValue<List<T>> — serverul folosește intern Drift .watch(); niciun client nu deschide Drift
  • Instrumentele MCP primesc dependențele ca parametri de constructor tipizați, niciodată Ref

Drift (SQLite), împărțit în două baze de date, ambele în cc_persistence:

  • GlobalDatabase (<dataDir>/global.db) — o listă scurtă de tabele cu adevărat globale pentru server: registrul spațiilor de lucru, utilizatori/preferințe/dispozitive asociate, conexiuni SSO, setări de server la nivel de instalare, politici de acțiuni administrate (o limită la nivel de instalare care poate doar înăspri guardrail-urile spațiilor de lucru), fluxul de știri per utilizator, coada flotei (workeri/sarcini/jurnal de plasare), indexul workspace_routes dinainte de autentificare și metadatele instalării. Procesul de pornire deschide doar acest fișier, așa că costul pornirii rămâne constant indiferent cât de mult istoric acumulează spațiile de lucru. Adăugarea unui tabel aici este o decizie de izolare pe care un test de tip ratchet te obligă să o argumentezi.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — un director pentru fiecare spațiu de lucru, care conține un fișier SQLite plus tot ce aparține doar acelui spațiu de lucru, astfel încât worktree-urile unei conversații și fișierele agenților stau lângă baza de date și sunt șterse odată cu ea. Aproape totul se află aici: agenți, spații, tichete, memorie, pipeline-uri, întâlniri, graful de cod, revizuiri, repo-uri. Fișierele se deschid la cerere, la prima atingere, prin WorkspaceDatabaseManager.of(workspaceId), și își plătesc atunci propriul quick_check, instalarea FTS/trigger și vector_init.

Ambele jumătăți au pornit de la o bază de referință v1 aplatizată — onCreate construiește schema actuală — cu pași de migrare adăugați doar atunci când o bază de date deja implementată trebuie dusă mai departe.

Deoarece un spațiu de lucru este un singur fișier, exportul unui spațiu de lucru este o singură instrucțiune VACUUM INTO (workspace.export), nu un dump tabel cu tabel; workspace.import adoptă un astfel de fișier și folosește workspace_meta încorporat pentru a distinge propriul fișier readoptat de unul provenit din altă instalare.

Ambele baze de date sunt deținute de cc_persistence și deschise doar de cc_server; niciun client nu deschide o bază de date — fiecare client citește/scrie prin RPC. Tabelele sunt definite în packages/cc_persistence/lib/database/tables/, DAO-urile în .../daos/; entitățile de domeniu sunt în Dart pur, separate de clasele de tabel Drift, cu maparea în straturile de date. FTS5 se ocupă de căutarea full-text, iar sqlite_vector de embeddinguri, ambele doar pe server; ambele baze de date rulează PRAGMA foreign_keys=ON în modul de jurnal WAL.

go_router, cu un singur fapt care guvernează restul: fiecare destinație din aplicație are prefixul spațiului de lucru sub forma /workspaces/:workspaceId/…. Id-ul spațiului de lucru din URL este singura sursă de adevăr pentru spațiul de lucru activ — activeWorkspaceIdProvider este condus de rută, nu invers — motiv pentru care citirea datelor care țin de un spațiu de lucru în afara unei rute cu prefix nu are de unde să ia id-ul.

  • Funcții de construire a rutelor în router/routes.dart, fiecare primind id-ul spațiului de lucru ca prim argument, nu constante de tip string
  • Patru rute dinainte de context nu au prefix: /splash, /onboarding, /signed-out și /workspaces (selectorul de spații de lucru)
  • ShellRoute învelește shell-ul aplicației; acele patru suprafețe se afișează pe tot ecranul, în afara lui
  • Gardă de autentificare: o configurare incompletă duce la /onboarding; o configurare completă a cărei acreditare de forge a dispărut duce la /signed-out. Complet înseamnă că cel puțin un forge este conectat pentru utilizatorul autentificat și că există cel puțin un spațiu de lucru — nu neapărat GitHub, iar CLI-ul gh nu este o metodă de autentificare
  • Poarta de onboarding în features/auth/providers/

Tot HTTP-ul extern se află pe server. Clienții nu apelează niciodată direct GitHub, Linear sau Google — ei apelează operațiuni RPC de pe server, iar chiar și conținutul media remote este adus prin endpointul /proxy/media al serverului, nu de la originea din amonte.

  • Clienți dio în packages/cc_infra/lib/src/: GitHubApiClient, GitHubPrClient, GitHubContentClient, GitHubGraphQLClient, GitLabApiClient, BitbucketApiClient, LinearGraphQlClient și GoogleCalendarApiClient
  • Injectarea tokenului de autentificare prin interceptori, inclusiv un interceptor Google OAuth per cont în cc_server_core, care reîmprospătează tokenurile la 401
  • Toate erorile sunt mapate prin packages/cc_infra/lib/src/network/error_mapper.dart → subclase tipizate AppException definite în cc_domain

Serverul deține acreditările care contează: tokenurile GitHub și de ticketing pentru lansarea agenților, cheile furnizorilor LLM și tokenurile OAuth se află toate în directorul de date al serverului sau în mediul acestuia. Ce mai păstrează keychainul clientului este propria acreditare de dispozitiv și cheia de asociere pentru un server remote.

  • Secrete în flutter_secure_storage (keychain macOS, credential manager Windows, libsecret Linux) — acreditarea de dispozitiv a clientului și cheile de asociere remote, nu tokenurile furnizorilor
  • shared_preferences doar pentru preferințe nesensibile (temă, font, aspect)
  • Secretele rămase de la furnizori din instalări mai vechi sunt predate serverului o singură dată de features/auth/providers/credential_migration.dart, apoi sunt șterse din keychainul clientului

Constrângerile de arhitectură sunt validate de test/core/architecture_constraints_test.dart, care eșuează dacă regulile de dependență sunt încălcate.