דלגו לתוכן

ארכיטקטורה

Control Center פועל לפי ארכיטקטורה נקייה מבוססת תכונות, בתבנית של יציאות ומתאמים. בסיס הקוד מאורגן סביב תחומי עסק (תכונות), עם כללי תלות נוקשים שנאכפים בבדיקת ארכיטקטורה אוטומטית.

לקוח דק מעל שרת ללא ממשק

קטע בשם “לקוח דק מעל שרת ללא ממשק”

Control Center הוא ארכיטקטורת לקוח דק. שום לקוח לא פותח את מסד הנתונים — תהליך cc_server ללא ממשק הוא הבעלים של הנתונים (קובץ global.db קטן ועוד קובץ SQLite אחד לכל סביבת עבודה, שנפתח לפי דרישה) ומגיש אותם דרך WebSocket RPC. אפליקציית המחשב, גרסת האינטרנט ובן הלוויה בטלפון — כולם מציגים ממשק מעל אותו חיבור RPC אחד.

אפליקציית המחשב בוחרת כיצד להגיע לשרת שלה בהפעלה הראשונה: מקומי (היא מפעילה cc_server על המכונה הזו ומתקשרת דרך loopback — ברירת המחדל) או מרוחק (היא מחייגת אל cc_server במקום אחר דרך WebSocket מאובטח). לקוח האינטרנט הוא תמיד מרוחק. פותר האתחול (bootstrap/server_backend.dart) בוחר את הנתיב לפני ש־Riverpod קיים, ואז דורס את rpcClientProvider עם הלקוח המחובר — כך שכל תכונה קוראת וכותבת דרך השרת ולא דרך מסד נתונים מקומי.

שכבת ה־RPC עצמה היא חסרת מצב: סשן מאומת, אבל אינו כבול לסביבת עבודה. כל קריאה בתחום סביבת עבודה נושאת את workspace_id משלה בארגומנטים שלה, והמפיץ מסרב לקריאה כזו בלעדיו. זה מה שמאפשר לשני לקוחות על אותו שרת להחזיק סביבות עבודה פעילות שונות בלי “סביבת עבודה נוכחית” שהשרת מחזיק ואפשר לחלוק עליה — ופירושו שהבידוד נאכף לכל קריאה, לא לכל חיבור.

מדובר בworkspace של Dart אחד עם lockfile יחיד שנפתר, בן עשרים ואחד חברים — חמישה־עשר חבילות ושישה יישומים — כשהיישום למחשב ולאינטרנט (control_center) הוא שורש ה־workspace. ששת היישומים הם קובץ ההרצה של השרת ללא ממשק (cc_server), מבצע הצי ללא ממשק (cc_worker), ה־PWA לטלפון (cc_remote), broker הממסר של WebSocket (cc_signaling_server), גלריית Widgetbook (cc_gallery) ומארח ההדגמה הציבורי (cc_demo_server). חמש־עשרה החבילות הן cc_ui (מערכת עיצוב), cc_domain (ליבה משותפת ב-Dart טהור: ישויות, אובייקטי ערך, יציאות, אירועים ושכבת התחום של כל תכונה), cc_harness (ליבת הסוכן המובנית: לולאת סוכן ב-Dart טהור — הודעות, יציאת ספק, כלים, דחיסה, היגוי, הוקים ותת־סוכנים — בלי dart:io ובלי תלות אחרת ב־cc_*), cc_harness_runtime (מתאם זמן הריצה של ה-harness המובנה: ספקי זרימה ל-VM בלבד, תיווך פרטי התחברות ב-OAuth/PKCE, מחסני פרטי התחברות וסט הכלים הגנרי — מריץ סוכנים בלי CLI חיצוני מותקן), cc_rpc (אמצעי תעבורה של הלקוח), cc_host (ליבת השרת), cc_data (מאגרים מרוחקים), cc_persistence (מסד נתונים בצד השרת), cc_server_core (הרכבה של שרת־היישום + זמן ריצה לזהות/נוכחות/צי/הערכות), cc_infra (מתאמים ל-VM בלבד), cc_mcp (כלי MCP), cc_mcp_client (לקוח לשרתי MCP חיצוניים, שמגשר את הכלים שלהם אל המרשם המקומי), cc_markdown (מנוע markdown בתוך המאגר), cc_natives (עלה FFI) ו־system_audio_capture. ראו פריסה ולקוחות למטריצת הלקוחות המלאה ולמה רץ היכן.

Presentation → Application/Providers → Domain ← Infrastructure
  • שכבת התחום: ישויות Dart טהורות, אובייקטי ערך, ממשקי מאגר, יציאות ושירותי תחום. אפס ייבוא תשתית (בלי dio, drift או מודלים של רשת).
  • שכבת התצוגה: מסכים, widgets ו-notifiers. בלי גישה ישירה ל־drift/DAO/שכבת הנתונים; הכול עובר דרך ספקי Riverpod ← מאגרים.
  • שכבת התשתית: מימושים קונקרטיים של יציאות התחום וממשקי המאגר. תלויה בתחום, ולעולם לא להפך.

הצורה המלאה של תכונה היא:

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

מעט מאוד תכונות תחת lib/features/ נושאות בפועל את כל ארבעת החלקים, והפערים מלמדים משהו ולא מקריים. הליבה המשותפת ספגה את domain/ של כל תכונה אל תוך cc_domain, והלקוח הדק אינו מחזיק מימושי מאגר, כך שרוב התכונות תחת lib/ הן presentation/ ועוד providers/ ותו לא. תיקיית data/ או domain/ ששורדת בלקוח מסמנת משהו שהוא באמת בצד הלקוח.

שני חריגים בשם כדאי להכיר:

  • mcp מכיל רק providers/. ממשק ההגדרות והמצב שלו יושב תחת settings/, ומשטח הכלים עצמו הוא חבילה (cc_mcp), לא תיקיית תכונה.
  • orchestration ו־plan_studio נושאים רק presentation/ ו־providers/.

תכונת guardrails שמה את שכבת התחום שלה ב־cc_domain/features/guardrails/ ולא ביישום השורש, מפני שהשרת אוכף אותה: כלל ממפה ActionClass (או commandPrefix של מעטפת) להחלטה — allow, prompt או deny — בהיקף כלשהו; כללים נשמרים לכל סביבת עבודה, וכל פעולת סוכן מפוקחת נפתרת דרך שרשרת המדיניות לפני שהיא רצה. הפתרון צועד ב־space > agent > workspace > mode preset > built-in default, וההיקף הראשון שיש בו כלל תואם הוא שמכריע; “המגביל ביותר” הוא רק שובר שוויון בתוך היקף אחד ולצורך שילוב של כמה סוגים שפעולה אחת מצהירה עליהם. המשטח הפונה למפעיל הוא הגדרות ← סביבת עבודה ← הרשאות סוכנים, בכתובת /workspaces/<id>/settings/workspace/permissions. ראו מעקות בטיחות למודל.

packages/cc_domain/lib/core/domain/ מכיל ישויות ומאגרים שמשותפים לשלוש תכונות ומעלה. הוא Dart טהור עם אפס תלויות תשתית, וזה מה שמאפשר גם ללקוח Flutter וגם לקובץ ההרצה של השרת נטול ה-Flutter לייבא את אותן הגדרות:

  • ישויות ליבה: Agent, AgentRunLog, Workspace, Repo, ReviewSpaceAssociation
  • זהות ורב־משתמשים: User, Principal (חתום UserPrincipal | AgentPrincipal), WorkspaceMember, WorkspaceRole
  • ישויות זיכרון: MemoryFact, MemoryPolicy, AgentWorkingMemory, MemoryAccessGrant
  • אובייקטי ערך משותפים: AgentSkills, AgentRole, Mode, SandboxBackend, SandboxSpec, RunCost
  • יציאות משותפות: SandboxPort, CredentialBrokerPort, WorkspaceFilesystemPort, GitRepoInspectorPort, EmbeddingPort
  • שירותי תחום: MemoryAccessPolicy, ActivityLogger, MentionResolver, RunLivenessClassifier
  • DomainEventBus + סוגי אירועים (סביבת עבודה/סוכן, PR/סקירה, הודעות, כרטוס, פייפליין, תזמור, זיכרון, לוח שנה/פגישות, זהות וחברות, נצפות)

Control Center הוא רב־משתמשים: בני אדם וסוכנים הם שחקנים שווי־מעמד שמאוחדים תחת Principal. חבר בסביבת עבודה הוא User הכבול ל־Workspace בתפקיד WorkspaceRole (owner/admin/member/viewer/guest). החברות היא גבול הגישה — לא מפתח שיוך — והקצאות לפי מאגר מונעות מחברות בסביבת עבודה להעניק הרשאות עודפות מעבר למארח הקוד. המשתמש הראשון שנוצר בשרת חדש הוא הבעלים של ההתקנה; אחרים מצטרפים בהזמנה או דרך OIDC. יצירת סביבת עבודה מסמנת את המשתמש הזה כ־owner.

שיתוף פעולה בזמן אמת הוא שרת סמכותי + “הכותב האחרון מנצח” (LWW) לכל שדה, לא CRDT. נוכחות (מי כאן, היכן הם נמצאים, סמנים, הקלדה, מצב חי של סוכן ועלות ריצה) היא מסלול ארעי נפרד שלעולם אינו נשמר; מצב עמיד נוסע על גב של מנגנון של עדכון אופטימי + rebase בשרת + LWW. בני אדם וסוכנים חולקים רשימת משתתפים אחת. כשמפעיל אנושי יחיד נוכח, מסלול הנוכחות אינו פעיל ולא מופיע שום ממשק רשימה — ריבוי משתמשים לא עולה למשתמש היחיד דבר.

יציאות הן ממשקים מופשטים בשכבת התחום. מתאמים הם מימושים קונקרטיים בתשתית. מכיוון שהשרת ללא ממשק מחזיק בנתונים וחייב להיבנות בלי Flutter, כמעט כל מתאם הוא ל-VM בלבד ויושב ב־packages/cc_infra/lib/src/, מאורגן לפי נושא:

יציאה מיקום המתאם
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/ (בצד הלקוח: מציג אירועי שרת באופן מקומי)
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 ו־GitHub Issues הם מתאמי סנכרון כרטיסים ב־cc_infra/lib/src/tickets/sync/; כרטיסים מקומיים חיים במסד הנתונים של סביבת העבודה בלי TicketProviderPort.
PipelineEnginePort cc_domain features/pipelines/domain/services/ (שירות תחום ב-Dart טהור — אינו זקוק לתשתית)

שורש ההרכבה של היישום (di/providers.dart) קושר יציאות בצד הלקוח למימושים דרך ספקי Riverpod; השרת מרכיב את המתאמים שלו ב־cc_server_core.

Riverpod לכל מצב הלקוח:

  • Notifier<T> ו־AsyncNotifier<T> למצב שניתן לשינוי
  • FutureProvider<T> לנתונים אסינכרוניים
  • רשימות ושורות חיות מגיעות דרך מינויים ב־RPC כ־AsyncValue<List<T>> — השרת משתמש ב-Drift .watch() באופן פנימי; שום לקוח לא פותח את Drift
  • כלי MCP מקבלים תלויות כפרמטרים של בנאי עם טיפוס, ולעולם לא Ref

Drift (SQLite), מפוצל לשני מסדי נתונים, שניהם ב־cc_persistence:

  • GlobalDatabase (<dataDir>/global.db) — רשימה קצרה של טבלאות שהן באמת כלל־שרתיות: מרשם סביבות העבודה, משתמשים/העדפות/מכשירים משויכים, חיבורי SSO, הגדרות שרת ברמת ההתקנה, מדיניויות פעולה מנוהלות (מנגנון ריסון ברמת ההתקנה שיכול רק להדק את מעקות הבטיחות של סביבת העבודה), עדכוני החדשות לכל משתמש, תור הצי (עובדים/משימות/יומן שיבוץ), אינדקס workspace_routes שלפני האימות ומטא־נתוני ההתקנה. האתחול פותח רק את הקובץ הזה, כך שעלות ההפעלה נשארת קבועה לא משנה כמה היסטוריה צוברות סביבות העבודה. הוספת טבלה כאן היא החלטת בידוד שבדיקה שמונעת נסיגה מכריחה אתכם לנמק.
  • WorkspaceDatabase (<dataDir>/<workspaceId>/workspace.db) — תיקייה אחת לכל סביבת עבודה, שמחזיקה קובץ SQLite אחד ועוד כל דבר ששייך רק לאותה סביבת עבודה, כך שעצי העבודה של שיחה וקבצי סוכן יושבים לצד מסד הנתונים ונמחקים יחד איתו. כמעט הכול חי כאן: סוכנים, מרחבים, כרטיסים, זיכרון, פייפליינים, פגישות, גרף הקוד, סקירות ומאגרים. הקבצים נפתחים לפי דרישה במגע הראשון דרך WorkspaceDatabaseManager.of(workspaceId) ומשלמים אז את ה־quick_check, התקנת FTS/טריגרים ו־vector_init שלהם.

שני החצאים החלו מבסיס v1 מאוחד — onCreate בונה את הסכימה הנוכחית — כששלבי נדידה נוספים רק כשצריך להעביר מסד נתונים שנפרס קדימה.

מכיוון שסביבת עבודה אחת היא קובץ אחד, ייצוא של סביבת עבודה הוא הצהרת VACUUM INTO אחת (workspace.export), ולא העתקה טבלה־אחר־טבלה; workspace.import מאמץ קובץ כזה ומשתמש ב־workspace_meta המוטמע כדי להבחין בין קובץ משלו שנאומץ מחדש לבין קובץ שהגיע מהתקנה אחרת.

שני מסדי הנתונים בבעלות cc_persistence ונפתחים רק בידי cc_server; שום לקוח לא פותח מסד נתונים — כל לקוח קורא/כותב דרך RPC. הטבלאות מוגדרות ב־packages/cc_persistence/lib/database/tables/, וה-DAO-ים ב־.../daos/; ישויות התחום הן Dart טהור, נפרדות ממחלקות הטבלה של Drift, עם מיפוי בשכבות הנתונים. FTS5 מטפל בחיפוש טקסט מלא ובהטמעות sqlite_vector, שניהם בצד השרת בלבד; שני מסדי הנתונים רצים עם PRAGMA foreign_keys=ON במצב יומן WAL.

go_router, עם עובדה אחת שמכתיבה את השאר: כל יעד בתוך היישום מקבל קידומת של סביבת עבודה בצורה /workspaces/:workspaceId/…. מזהה סביבת העבודה בכתובת הוא מקור האמת היחיד לסביבת העבודה הפעילה — activeWorkspaceIdProvider נגזר מהנתיב, ולא להפך — ולכן לקריאת נתונים בתחום סביבת עבודה מחוץ לנתיב עם קידומת אין מאיפה לקבל את המזהה.

  • פונקציות בונות נתיבים ב־router/routes.dart, שכל אחת מקבלת את מזהה סביבת העבודה כארגומנט הראשון שלה, ולא קבועי מחרוזת
  • ארבעה נתיבים שאינם תלויים בהקשר ואינם עם קידומת: /splash, /onboarding, /signed-out ו־/workspaces (בורר סביבות העבודה)
  • ShellRoute עוטף את מסגרת היישום; ארבעת המשטחים האלה מוצגים במסך מלא מחוצה לו
  • שומר אימות: הגדרה שלא הושלמה מפנה ל־/onboarding; הגדרה שהושלמה שפרטי ההתחברות של מארח הקוד שלה נעלמו מפנה ל־/signed-out. “הושלמה” פירושו שלמשתמש המחובר מחובר לפחות מארח קוד אחד ולפחות סביבת עבודה אחת קיימת — לאו דווקא GitHub, וה־CLI בשם gh אינו שיטת אימות
  • שער הקליטה ב־features/auth/providers/

כל ה־HTTP החיצוני חי בשרת. לקוחות לעולם אינם מחייגים ישירות ל־GitHub, ל־Linear או ל־Google — הם קוראים לפעולות RPC בשרת, ואפילו מדיה מרוחקת נשלפת דרך נקודת הקצה /proxy/media של השרת ולא מהמקור במעלה הזרם.

  • לקוחות dio ב־packages/cc_infra/lib/src/: GitHubApiClient, GitHubPrClient, GitHubContentClient, GitHubGraphQLClient, GitLabApiClient, BitbucketApiClient, LinearGraphQlClient ו־GoogleCalendarApiClient
  • הזרקת אסימון אימות דרך interceptors, כולל interceptor של Google OAuth לכל חשבון ב־cc_server_core שמרענן אסימונים בשגיאת 401
  • כל השגיאות ממופות דרך packages/cc_infra/lib/src/network/error_mapper.dart → מחלקות משנה של AppException עם טיפוסים, שמוגדרות ב־cc_domain

השרת מחזיק בפרטי ההתחברות שקובעים: אסימוני GitHub וכרטוס להרצת סוכנים, מפתחות ספקי LLM ואסימוני OAuth — כולם חיים תחת תיקיית הנתונים של השרת או בסביבה שלו. מה שמחזיק המפתחות של הלקוח מחזיק בו עדיין הוא פרטי ההתחברות של המכשיר עצמו ומפתח השיוך לשרת מרוחק.

  • סודות ב־flutter_secure_storage (keychain ב-macOS, מנהל פרטי התחברות ב-Windows, libsecret ב-Linux) — פרטי ההתחברות של המכשיר בצד הלקוח ומפתחות שיוך למרוחק, לא אסימוני ספקים
  • shared_preferences רק להעדפות לא רגישות (ערכת נושא, גופן, פריסה)
  • סודות ספקים שנותרו מהתקנות ישנות נמסרים לשרת פעם אחת בידי features/auth/providers/credential_migration.dart, ואז נמחקים ממחזיק המפתחות של הלקוח

אילוצי הארכיטקטורה נאמתים בידי test/core/architecture_constraints_test.dart, שנכשל אם כללי התלות מופרים.