diff --git a/scripts/ci/source-file-size-baseline.json b/scripts/ci/source-file-size-baseline.json index 39b42d7357..9ab80ea5e6 100644 --- a/scripts/ci/source-file-size-baseline.json +++ b/scripts/ci/source-file-size-baseline.json @@ -85,7 +85,7 @@ "src/main/services/team/TeamBackupService.ts": 1236, "src/main/services/team/TeamBootstrapStateReader.ts": 908, "src/main/services/team/TeamConfigReader.ts": 1151, - "src/main/services/team/TeamDataService.ts": 4126, + "src/main/services/team/TeamDataService.ts": 4121, "src/main/services/team/TeamInboxReader.ts": 820, "src/main/services/team/TeamLaunchStateEvaluator.ts": 1059, "src/main/services/team/TeamLogSourceTracker.ts": 1088, @@ -121,12 +121,12 @@ "src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts": 1640, "src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx": 1206, "src/renderer/components/team/dialogs/TaskDetailDialog.tsx": 1717, - "src/renderer/components/team/dialogs/TeamModelSelector.tsx": 3881, + "src/renderer/components/team/dialogs/TeamModelSelector.tsx": 3864, "src/renderer/components/team/editor/EditorFileTree.tsx": 907, "src/renderer/components/team/editor/ProjectEditorOverlay.tsx": 929, "src/renderer/components/team/kanban/KanbanBoard.tsx": 919, "src/renderer/components/team/kanban/KanbanTaskCard.tsx": 1081, - "src/renderer/components/team/members/MemberCard.tsx": 1635, + "src/renderer/components/team/members/MemberCard.tsx": 1586, "src/renderer/components/team/members/MemberDraftRow.tsx": 1018, "src/renderer/components/team/members/MemberList.tsx": 1370, "src/renderer/components/team/members/MemberLogsTab.tsx": 985, diff --git a/src/features/localization/renderer/locales/ar/team.json b/src/features/localization/renderer/locales/ar/team.json index 248ef1cbf6..0c01be1bba 100644 --- a/src/features/localization/renderer/locales/ar/team.json +++ b/src/features/localization/renderer/locales/ar/team.json @@ -899,7 +899,9 @@ "runtime": "ويستخدم التخلف عن الدفع في الوقت المناسب للمزود المختار." }, "multimodelOff": "مضاعف", - "unavailableInRuntime": "غير متاح في الوقت الحالي" + "unavailableInRuntime": "غير متاح في الوقت الحالي", + "defaultWithResolved": "الافتراضي - {{model}}", + "explicitChoice": "اختيار صريح - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/bn/team.json b/src/features/localization/renderer/locales/bn/team.json index 6c9436bc19..158114db22 100644 --- a/src/features/localization/renderer/locales/bn/team.json +++ b/src/features/localization/renderer/locales/bn/team.json @@ -899,7 +899,9 @@ "runtime": "নির্বাচিত উপলব্ধকারীর ডিফল্ট পদ্ধতি ব্যবহার করতে ব্যবহৃত হয় ।" }, "multimodelOff": "মাল্টি মডেল বন্ধ করুন", - "unavailableInRuntime": "বর্তমানে চলমান নয়" + "unavailableInRuntime": "বর্তমানে চলমান নয়", + "defaultWithResolved": "ডিফল্ট - {{model}}", + "explicitChoice": "স্পষ্ট নির্বাচন - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/de/team.json b/src/features/localization/renderer/locales/de/team.json index 1bd37a94ce..a7f43fd31b 100644 --- a/src/features/localization/renderer/locales/de/team.json +++ b/src/features/localization/renderer/locales/de/team.json @@ -899,7 +899,9 @@ "runtime": "Benutzt die Laufzeiteinstellung für den ausgewählten Anbieter." }, "multimodelOff": "Multimodel ab", - "unavailableInRuntime": "Nicht verfügbar in der aktuellen Laufzeit" + "unavailableInRuntime": "Nicht verfügbar in der aktuellen Laufzeit", + "defaultWithResolved": "Standard - {{model}}", + "explicitChoice": "Explizite Auswahl - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/en/team.json b/src/features/localization/renderer/locales/en/team.json index b924955e62..7c9905d04c 100644 --- a/src/features/localization/renderer/locales/en/team.json +++ b/src/features/localization/renderer/locales/en/team.json @@ -934,7 +934,9 @@ "runtime": "Uses the runtime default for the selected provider." }, "multimodelOff": "Multimodel off", - "unavailableInRuntime": "Unavailable in current runtime" + "unavailableInRuntime": "Unavailable in current runtime", + "defaultWithResolved": "Default - {{model}}", + "explicitChoice": "Explicit choice - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/es/team.json b/src/features/localization/renderer/locales/es/team.json index f4b95dfb7e..c6575871b5 100644 --- a/src/features/localization/renderer/locales/es/team.json +++ b/src/features/localization/renderer/locales/es/team.json @@ -899,7 +899,9 @@ "runtime": "Utiliza el predeterminado de tiempo de ejecución para el proveedor seleccionado." }, "multimodelOff": "Multimodel off", - "unavailableInRuntime": "No disponible en tiempo de ejecución actual" + "unavailableInRuntime": "No disponible en tiempo de ejecución actual", + "defaultWithResolved": "Predeterminado - {{model}}", + "explicitChoice": "Selección explícita - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/fa/team.json b/src/features/localization/renderer/locales/fa/team.json index 237e01830c..3fcd99591d 100644 --- a/src/features/localization/renderer/locales/fa/team.json +++ b/src/features/localization/renderer/locales/fa/team.json @@ -899,7 +899,9 @@ "runtime": "از پیش‌فرض زمان اجرا برای ارائه‌دهنده انتخاب‌شده استفاده می‌کند." }, "multimodelOff": "چندمدلی خاموش", - "unavailableInRuntime": "در زمان اجرای فعلی در دسترس نیست" + "unavailableInRuntime": "در زمان اجرای فعلی در دسترس نیست", + "defaultWithResolved": "پیش‌فرض - {{model}}", + "explicitChoice": "انتخاب صریح - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/fil/team.json b/src/features/localization/renderer/locales/fil/team.json index e29b776f3a..6cd7bfd5cb 100644 --- a/src/features/localization/renderer/locales/fil/team.json +++ b/src/features/localization/renderer/locales/fil/team.json @@ -899,7 +899,9 @@ "runtime": "Ginagamit ang runtime default para sa napiling provider." }, "multimodelOff": "Naka-off ang Multimodel", - "unavailableInRuntime": "Hindi available sa kasalukuyang runtime" + "unavailableInRuntime": "Hindi available sa kasalukuyang runtime", + "defaultWithResolved": "Paunang itinakda - {{model}}", + "explicitChoice": "Tahasang pagpili - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/fr/team.json b/src/features/localization/renderer/locales/fr/team.json index db0fc1c98e..68b89f4436 100644 --- a/src/features/localization/renderer/locales/fr/team.json +++ b/src/features/localization/renderer/locales/fr/team.json @@ -899,7 +899,9 @@ "runtime": "Utilise la valeur par défaut d'exécution pour le fournisseur sélectionné." }, "multimodelOff": "Modèle multiple désactivé", - "unavailableInRuntime": "Indisponible dans l'exécution actuelle" + "unavailableInRuntime": "Indisponible dans l'exécution actuelle", + "defaultWithResolved": "Par défaut - {{model}}", + "explicitChoice": "Choix explicite - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/hi/team.json b/src/features/localization/renderer/locales/hi/team.json index 5ef4ccc0e5..0934ce1868 100644 --- a/src/features/localization/renderer/locales/hi/team.json +++ b/src/features/localization/renderer/locales/hi/team.json @@ -899,7 +899,9 @@ "runtime": "चयनित प्रदाता के लिए रनटाइम डिफ़ॉल्ट का उपयोग करता है।" }, "multimodelOff": "मल्टीमॉडल बंद", - "unavailableInRuntime": "वर्तमान समय में अनुपलब्ध" + "unavailableInRuntime": "वर्तमान समय में अनुपलब्ध", + "defaultWithResolved": "डिफ़ॉल्ट - {{model}}", + "explicitChoice": "स्पष्ट चयन - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/id/team.json b/src/features/localization/renderer/locales/id/team.json index ffc8b6da74..e9a5b55883 100644 --- a/src/features/localization/renderer/locales/id/team.json +++ b/src/features/localization/renderer/locales/id/team.json @@ -899,7 +899,9 @@ "runtime": "Menggunakan waktu-jalan default bagi penyedia yang dipilih." }, "multimodelOff": "Matikan multimodel", - "unavailableInRuntime": "Tidak tersedia dalam waktu-jalan saat ini" + "unavailableInRuntime": "Tidak tersedia dalam waktu-jalan saat ini", + "defaultWithResolved": "Bawaan - {{model}}", + "explicitChoice": "Pilihan eksplisit - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/it/team.json b/src/features/localization/renderer/locales/it/team.json index 7cfde0089d..a3a2849311 100644 --- a/src/features/localization/renderer/locales/it/team.json +++ b/src/features/localization/renderer/locales/it/team.json @@ -899,7 +899,9 @@ "runtime": "Usa il valore predefinito del runtime per il provider selezionato." }, "multimodelOff": "Multimodello disattivato", - "unavailableInRuntime": "Non disponibile nel runtime attuale" + "unavailableInRuntime": "Non disponibile nel runtime attuale", + "defaultWithResolved": "Predefinito - {{model}}", + "explicitChoice": "Scelta esplicita - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/ja/team.json b/src/features/localization/renderer/locales/ja/team.json index eeb2306516..c792efa3c1 100644 --- a/src/features/localization/renderer/locales/ja/team.json +++ b/src/features/localization/renderer/locales/ja/team.json @@ -899,7 +899,9 @@ "runtime": "選択したプロバイダのランタイムデフォルトを使用します。" }, "multimodelOff": "マルチモデルオフ", - "unavailableInRuntime": "現在のランタイムで利用不可" + "unavailableInRuntime": "現在のランタイムで利用不可", + "defaultWithResolved": "デフォルト - {{model}}", + "explicitChoice": "明示的な選択 - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/ko/team.json b/src/features/localization/renderer/locales/ko/team.json index 03fd356edb..88a4b24c20 100644 --- a/src/features/localization/renderer/locales/ko/team.json +++ b/src/features/localization/renderer/locales/ko/team.json @@ -899,7 +899,9 @@ "runtime": "선택한 공급자의 runtime default를 사용합니다." }, "multimodelOff": "Multimodel 오프", - "unavailableInRuntime": "현재 런타임에서 사용 가능" + "unavailableInRuntime": "현재 런타임에서 사용할 수 없음", + "defaultWithResolved": "기본값 - {{model}}", + "explicitChoice": "명시적 선택 - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/mr/team.json b/src/features/localization/renderer/locales/mr/team.json index 8464c5fc66..de7164a37f 100644 --- a/src/features/localization/renderer/locales/mr/team.json +++ b/src/features/localization/renderer/locales/mr/team.json @@ -899,7 +899,9 @@ "runtime": "निवडलेल्या प्रोव्हायडरसाठी रनटाइम डीफॉल्ट वापरते." }, "multimodelOff": "मल्टीमॉडेल बंद", - "unavailableInRuntime": "सध्याच्या रनटाइममध्ये उपलब्ध नाही" + "unavailableInRuntime": "सध्याच्या रनटाइममध्ये उपलब्ध नाही", + "defaultWithResolved": "डीफॉल्ट - {{model}}", + "explicitChoice": "स्पष्ट निवड - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/ms/team.json b/src/features/localization/renderer/locales/ms/team.json index 1b31092505..d91fc03e18 100644 --- a/src/features/localization/renderer/locales/ms/team.json +++ b/src/features/localization/renderer/locales/ms/team.json @@ -899,7 +899,9 @@ "runtime": "Menggunakan lalai runtime untuk pembekal yang dipilih." }, "multimodelOff": "Multimodel mati", - "unavailableInRuntime": "Tidak tersedia dalam runtime semasa" + "unavailableInRuntime": "Tidak tersedia dalam runtime semasa", + "defaultWithResolved": "Lalai - {{model}}", + "explicitChoice": "Pilihan eksplisit - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/nl/team.json b/src/features/localization/renderer/locales/nl/team.json index 79353b72a2..cd5036f6e9 100644 --- a/src/features/localization/renderer/locales/nl/team.json +++ b/src/features/localization/renderer/locales/nl/team.json @@ -899,7 +899,9 @@ "runtime": "Gebruikt de runtimestandaard voor de geselecteerde provider." }, "multimodelOff": "Multimodel uit", - "unavailableInRuntime": "Niet beschikbaar in huidige runtime" + "unavailableInRuntime": "Niet beschikbaar in huidige runtime", + "defaultWithResolved": "Standaard - {{model}}", + "explicitChoice": "Expliciete keuze - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/pl/team.json b/src/features/localization/renderer/locales/pl/team.json index c46f90cacd..be9513f8e9 100644 --- a/src/features/localization/renderer/locales/pl/team.json +++ b/src/features/localization/renderer/locales/pl/team.json @@ -899,7 +899,9 @@ "runtime": "Używa domyślnego modelu środowiska uruchomieniowego dla wybranego dostawcy." }, "multimodelOff": "Wielomodelowy wył.", - "unavailableInRuntime": "Niedostępne w bieżącym środowisku uruchomieniowym" + "unavailableInRuntime": "Niedostępne w bieżącym środowisku uruchomieniowym", + "defaultWithResolved": "Domyślny - {{model}}", + "explicitChoice": "Jawny wybór - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/pt/team.json b/src/features/localization/renderer/locales/pt/team.json index ebe2e1378b..1a2cdeadff 100644 --- a/src/features/localization/renderer/locales/pt/team.json +++ b/src/features/localization/renderer/locales/pt/team.json @@ -899,7 +899,9 @@ "runtime": "Usa o padrão de tempo de execução para o provedor selecionado." }, "multimodelOff": "Multimodelo desligado", - "unavailableInRuntime": "Indisponível no tempo de execução atual" + "unavailableInRuntime": "Indisponível no tempo de execução atual", + "defaultWithResolved": "Padrão - {{model}}", + "explicitChoice": "Escolha explícita - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/ro/team.json b/src/features/localization/renderer/locales/ro/team.json index dd747721de..a42c4bbf41 100644 --- a/src/features/localization/renderer/locales/ro/team.json +++ b/src/features/localization/renderer/locales/ro/team.json @@ -899,7 +899,9 @@ "runtime": "Folosește valoarea implicită a runtime-ului pentru furnizorul selectat." }, "multimodelOff": "Multimodel dezactivat", - "unavailableInRuntime": "Indisponibil în runtime-ul curent" + "unavailableInRuntime": "Indisponibil în runtime-ul curent", + "defaultWithResolved": "Implicit - {{model}}", + "explicitChoice": "Alegere explicită - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/ru/team.json b/src/features/localization/renderer/locales/ru/team.json index 7dc87f7220..9d9b62f882 100644 --- a/src/features/localization/renderer/locales/ru/team.json +++ b/src/features/localization/renderer/locales/ru/team.json @@ -931,7 +931,9 @@ "runtime": "Использует значение по умолчанию из рантайма для выбранного провайдера." }, "multimodelOff": "Multimodel выключен", - "unavailableInRuntime": "Недоступно в текущем рантайме" + "unavailableInRuntime": "Недоступно в текущем рантайме", + "defaultWithResolved": "По умолчанию - {{model}}", + "explicitChoice": "Выбрано явно - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/sw/team.json b/src/features/localization/renderer/locales/sw/team.json index e6b49f048f..2fc3820122 100644 --- a/src/features/localization/renderer/locales/sw/team.json +++ b/src/features/localization/renderer/locales/sw/team.json @@ -899,7 +899,9 @@ "runtime": "Inatumia chaguo-msingi ya runtime kwa mtoa huduma aliyechaguliwa." }, "multimodelOff": "Modeli nyingi imezimwa", - "unavailableInRuntime": "Haipatikani katika runtime ya sasa" + "unavailableInRuntime": "Haipatikani katika runtime ya sasa", + "defaultWithResolved": "Chaguo-msingi - {{model}}", + "explicitChoice": "Chaguo bayana - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/ta/team.json b/src/features/localization/renderer/locales/ta/team.json index 5b0a7e0aef..aeffac5c3b 100644 --- a/src/features/localization/renderer/locales/ta/team.json +++ b/src/features/localization/renderer/locales/ta/team.json @@ -899,7 +899,9 @@ "runtime": "தேர்ந்தெடுத்த வழங்குநருக்கான runtime இயல்புநிலையைப் பயன்படுத்துகிறது." }, "multimodelOff": "Multimodel off", - "unavailableInRuntime": "தற்போதைய runtime இல் கிடைக்கவில்லை" + "unavailableInRuntime": "தற்போதைய runtime இல் கிடைக்கவில்லை", + "defaultWithResolved": "இயல்புநிலை - {{model}}", + "explicitChoice": "வெளிப்படையான தேர்வு - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/te/team.json b/src/features/localization/renderer/locales/te/team.json index a2f0eb46d8..01f9e3ac38 100644 --- a/src/features/localization/renderer/locales/te/team.json +++ b/src/features/localization/renderer/locales/te/team.json @@ -899,7 +899,9 @@ "runtime": "ఎంచుకున్న ప్రొవైడర్ కోసం రన్‌టైమ్ డిఫాల్ట్‌ను ఉపయోగిస్తుంది." }, "multimodelOff": "Multimodel ఆఫ్", - "unavailableInRuntime": "ప్రస్తుత రన్‌టైమ్‌లో అందుబాటులో లేదు" + "unavailableInRuntime": "ప్రస్తుత రన్‌టైమ్‌లో అందుబాటులో లేదు", + "defaultWithResolved": "డిఫాల్ట్ - {{model}}", + "explicitChoice": "స్పష్ట ఎంపిక - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/th/team.json b/src/features/localization/renderer/locales/th/team.json index d3411bfe1d..b0a7f78091 100644 --- a/src/features/localization/renderer/locales/th/team.json +++ b/src/features/localization/renderer/locales/th/team.json @@ -899,7 +899,9 @@ "runtime": "ใช้ค่าเริ่มต้นของรันไทม์สำหรับผู้ให้บริการที่เลือก" }, "multimodelOff": "ปิด Multimodel", - "unavailableInRuntime": "ไม่พร้อมใช้งานในรันไทม์ปัจจุบัน" + "unavailableInRuntime": "ไม่พร้อมใช้งานในรันไทม์ปัจจุบัน", + "defaultWithResolved": "ค่าเริ่มต้น - {{model}}", + "explicitChoice": "ตัวเลือกที่ระบุ - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/tr/team.json b/src/features/localization/renderer/locales/tr/team.json index dd8b3ecf5e..9b52194f97 100644 --- a/src/features/localization/renderer/locales/tr/team.json +++ b/src/features/localization/renderer/locales/tr/team.json @@ -899,7 +899,9 @@ "runtime": "Seçili sağlayıcı için çalışma zamanı varsayılanını kullanır." }, "multimodelOff": "Çoklu model kapalı", - "unavailableInRuntime": "Geçerli çalışma zamanında kullanılamıyor" + "unavailableInRuntime": "Geçerli çalışma zamanında kullanılamıyor", + "defaultWithResolved": "Varsayılan - {{model}}", + "explicitChoice": "Açık seçim - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/uk/team.json b/src/features/localization/renderer/locales/uk/team.json index e6e52fc332..33b5ca00f9 100644 --- a/src/features/localization/renderer/locales/uk/team.json +++ b/src/features/localization/renderer/locales/uk/team.json @@ -901,7 +901,9 @@ "runtime": "Використовує значення середовища за замовчуванням для вибраного провайдера." }, "multimodelOff": "Мультимодель вимкнено", - "unavailableInRuntime": "Недоступно в поточному середовищі" + "unavailableInRuntime": "Недоступно в поточному середовищі", + "defaultWithResolved": "За замовчуванням - {{model}}", + "explicitChoice": "Вибрано явно - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/ur/team.json b/src/features/localization/renderer/locales/ur/team.json index 8b315145b1..bb1c2402f4 100644 --- a/src/features/localization/renderer/locales/ur/team.json +++ b/src/features/localization/renderer/locales/ur/team.json @@ -899,7 +899,9 @@ "runtime": "منتخب کردہ فراہم کُنندہ کے لیے چلائیں" }, "multimodelOff": "معطل", - "unavailableInRuntime": "موجودہ دور میں غیر متصل" + "unavailableInRuntime": "موجودہ دور میں غیر متصل", + "defaultWithResolved": "طے شدہ - {{model}}", + "explicitChoice": "واضح انتخاب - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/vi/team.json b/src/features/localization/renderer/locales/vi/team.json index 7fdebd421b..2c69e17ec5 100644 --- a/src/features/localization/renderer/locales/vi/team.json +++ b/src/features/localization/renderer/locales/vi/team.json @@ -899,7 +899,9 @@ "runtime": "Dùng mặc định runtime cho nhà cung cấp đã chọn." }, "multimodelOff": "Đa mô hình tắt", - "unavailableInRuntime": "Không khả dụng trong runtime hiện tại" + "unavailableInRuntime": "Không khả dụng trong runtime hiện tại", + "defaultWithResolved": "Mặc định - {{model}}", + "explicitChoice": "Lựa chọn rõ ràng - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/locales/zh/team.json b/src/features/localization/renderer/locales/zh/team.json index d37fcfda79..ef2d756cbf 100644 --- a/src/features/localization/renderer/locales/zh/team.json +++ b/src/features/localization/renderer/locales/zh/team.json @@ -899,7 +899,9 @@ "runtime": "使用所选提供商的运行时默认值。" }, "multimodelOff": "多模型关闭", - "unavailableInRuntime": "当前运行时不可用" + "unavailableInRuntime": "当前运行时不可用", + "defaultWithResolved": "默认 - {{model}}", + "explicitChoice": "显式选择 - {{model}}" }, "taskDetail": { "actions": { diff --git a/src/features/localization/renderer/resources.d.ts b/src/features/localization/renderer/resources.d.ts index ee892894a8..ffe23d539c 100644 --- a/src/features/localization/renderer/resources.d.ts +++ b/src/features/localization/renderer/resources.d.ts @@ -4788,6 +4788,7 @@ export default interface Resources { openCodeWithResolved: 'Uses the OpenCode default model.\nCurrently resolves to {{model}}.'; runtime: 'Uses the runtime default for the selected provider.'; }; + defaultWithResolved: 'Default - {{model}}'; empty: { freeOpenCode: 'No free OpenCode models are available in the current runtime list.'; noModels: 'No models are available in the current runtime list.'; @@ -4795,6 +4796,7 @@ export default interface Resources { recommendedFreeOpenCode: 'No recommended free OpenCode models are available in the current runtime list.'; recommendedOpenCode: 'No recommended OpenCode models are available in the current runtime list.'; }; + explicitChoice: 'Explicit choice - {{model}}'; fastMode: { codexLabel: 'Fast mode (2x credits)'; defaultFast: 'Default (Fast)'; diff --git a/src/features/team-provisioning/contracts/api.ts b/src/features/team-provisioning/contracts/api.ts index 4402e71de4..81bb7645f2 100644 --- a/src/features/team-provisioning/contracts/api.ts +++ b/src/features/team-provisioning/contracts/api.ts @@ -1,5 +1,10 @@ +import type { UpdateMemberSettingsRequest, UpdateMemberSettingsResult } from './memberSettings'; import type { TeamProvisioningProgress } from '@shared/types/team'; export interface TeamProvisioningStatusApi { getProvisioningStatus(runId: string): Promise; } + +export interface TeamMemberSettingsApi { + updateMemberSettings(request: UpdateMemberSettingsRequest): Promise; +} diff --git a/src/features/team-provisioning/contracts/channels.ts b/src/features/team-provisioning/contracts/channels.ts new file mode 100644 index 0000000000..c2a73c5dd1 --- /dev/null +++ b/src/features/team-provisioning/contracts/channels.ts @@ -0,0 +1 @@ +export const TEAM_UPDATE_MEMBER_SETTINGS = 'team:updateMemberSettings'; diff --git a/src/features/team-provisioning/contracts/index.ts b/src/features/team-provisioning/contracts/index.ts index 6561443d1b..6d027416f7 100644 --- a/src/features/team-provisioning/contracts/index.ts +++ b/src/features/team-provisioning/contracts/index.ts @@ -1 +1,3 @@ export type * from './api'; +export * from './channels'; +export type * from './memberSettings'; diff --git a/src/features/team-provisioning/contracts/memberSettings.ts b/src/features/team-provisioning/contracts/memberSettings.ts new file mode 100644 index 0000000000..642fc25c02 --- /dev/null +++ b/src/features/team-provisioning/contracts/memberSettings.ts @@ -0,0 +1,90 @@ +export type MemberSettingsProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; +export type MemberSettingsProviderBackendId = + | 'auto' + | 'adapter' + | 'api' + | 'cli-sdk' + | 'codex-native' + | 'opencode-cli'; +export type MemberSettingsEffort = + | 'none' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max' + | 'ultra'; +export type MemberSettingsFastMode = 'inherit' | 'on' | 'off'; +export type MemberSettingsMcpScope = 'user' | 'project' | 'local'; +export type MemberSettingsMcpMode = 'inheritLead' | 'inheritScopes' | 'strictAllowlist' | 'appOnly'; + +export interface MemberSettingsMcpPolicy { + mode: MemberSettingsMcpMode; + scopes?: Partial>; + serverNames?: string[]; +} + +/** + * Complete editable settings for one existing team member. + * + * Every field is required. `null` explicitly clears an optional persisted + * value; request producers must not use `undefined` as an implicit patch. + */ +export interface EditableMemberSettings { + role: string | null; + workflow: string | null; + isolation: 'worktree' | null; + providerId: MemberSettingsProviderId | null; + providerBackendId: MemberSettingsProviderBackendId | null; + model: string | null; + effort: MemberSettingsEffort | null; + fastMode: MemberSettingsFastMode | null; + mcpPolicy: MemberSettingsMcpPolicy | null; +} + +export type UpdateMemberSettingsEffect = + | 'no_changes' + | 'persisted_only' + | 'member_restart_started' + | 'opencode_lane_restart_started' + | 'team_relaunch_required' + | 'recovery_required'; + +export interface UpdateMemberSettingsRequest { + commandId: string; + idempotencyKey: string; + teamName: string; + memberName: string; + expectedFingerprint: string; + settings: EditableMemberSettings; +} + +export type UpdateMemberSettingsResult = + | { + outcome: 'completed'; + effect: UpdateMemberSettingsEffect; + memberName: string; + previousFingerprint: string; + currentFingerprint: string; + replayed: boolean; + recovery?: { + persistenceRestored: boolean; + lifecycleRestored: boolean; + cause: string; + }; + } + | { + outcome: 'target_conflict'; + memberName: string; + expectedFingerprint: string; + actualFingerprint: string | null; + reason: 'target_changed' | 'member_not_found' | 'team_not_found'; + replayed: boolean; + } + | { + outcome: 'busy'; + teamName: string; + memberName: string; + replayed: boolean; + }; diff --git a/src/features/team-provisioning/core/application/ports/UpdateMemberSettingsPorts.ts b/src/features/team-provisioning/core/application/ports/UpdateMemberSettingsPorts.ts new file mode 100644 index 0000000000..9fe42b77d3 --- /dev/null +++ b/src/features/team-provisioning/core/application/ports/UpdateMemberSettingsPorts.ts @@ -0,0 +1,72 @@ +import type { + EditableMemberSettings, + UpdateMemberSettingsEffect, +} from '../../../contracts/memberSettings'; +import type { + MemberSettingsLifecycleAction, + MemberSettingsTargetSnapshot, +} from '../../domain/memberSettingsPolicy'; + +export class MemberSettingsPersistenceFailedError extends Error { + constructor( + message: string, + readonly recoveryRequired: boolean, + readonly cause?: unknown + ) { + super(message); + this.name = 'MemberSettingsPersistenceFailedError'; + } +} + +export class MemberSettingsMutationBusyError extends Error { + constructor(readonly teamName: string) { + super(`Team mutation is already in progress: ${teamName}`); + this.name = 'MemberSettingsMutationBusyError'; + } +} + +export interface MemberSettingsMutationGatePort { + runExclusive(teamName: string, operation: () => Promise): Promise; +} + +export type ApplyMemberSettingsResult = + | { outcome: 'applied'; snapshot: MemberSettingsTargetSnapshot; rollbackToken: unknown } + | { outcome: 'target_conflict'; current: MemberSettingsTargetSnapshot | null }; + +export interface MemberSettingsRepositoryPort { + findTarget(teamName: string, memberName: string): Promise; + classifyMissingTarget(teamName: string): Promise<'member_not_found' | 'team_not_found'>; + applyTarget(input: { + teamName: string; + memberName: string; + expectedFingerprint: string; + settings: EditableMemberSettings; + }): Promise; + restoreTarget(input: { + teamName: string; + memberName: string; + expectedFingerprint: string; + snapshot: MemberSettingsTargetSnapshot; + rollbackToken: unknown; + }): Promise; +} + +export type MemberSettingsLifecycleEffect = Exclude< + UpdateMemberSettingsEffect, + 'no_changes' | 'recovery_required' +>; + +export interface MemberSettingsLifecyclePort { + applyEffect(input: { + teamName: string; + before: MemberSettingsTargetSnapshot; + after: MemberSettingsTargetSnapshot; + action: Exclude; + }): Promise; + restore(input: { + teamName: string; + before: MemberSettingsTargetSnapshot; + after: MemberSettingsTargetSnapshot; + attemptedAction: Exclude; + }): Promise; +} diff --git a/src/features/team-provisioning/core/application/use-cases/UpdateMemberSettingsUseCase.ts b/src/features/team-provisioning/core/application/use-cases/UpdateMemberSettingsUseCase.ts new file mode 100644 index 0000000000..3b576b8db5 --- /dev/null +++ b/src/features/team-provisioning/core/application/use-cases/UpdateMemberSettingsUseCase.ts @@ -0,0 +1,213 @@ +import { + createMemberSettingsFingerprint, + normalizeEditableMemberSettings, + selectMemberSettingsLifecycleAction, +} from '../../domain/memberSettingsPolicy'; +import { + MemberSettingsMutationBusyError, + MemberSettingsPersistenceFailedError, +} from '../ports/UpdateMemberSettingsPorts'; + +import type { + UpdateMemberSettingsRequest, + UpdateMemberSettingsResult, +} from '../../../contracts/memberSettings'; +import type { + MemberSettingsLifecyclePort, + MemberSettingsMutationGatePort, + MemberSettingsRepositoryPort, +} from '../ports/UpdateMemberSettingsPorts'; + +export interface UpdateMemberSettingsUseCaseDependencies { + mutationGate: MemberSettingsMutationGatePort; + repository: MemberSettingsRepositoryPort; + lifecycle: MemberSettingsLifecyclePort; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export class UpdateMemberSettingsUseCase { + constructor(private readonly dependencies: UpdateMemberSettingsUseCaseDependencies) {} + + async execute(request: UpdateMemberSettingsRequest): Promise { + try { + return await this.dependencies.mutationGate.runExclusive(request.teamName, async () => { + const current = await this.dependencies.repository.findTarget( + request.teamName, + request.memberName + ); + const actualFingerprint = current ? createMemberSettingsFingerprint(current) : null; + if (!current || actualFingerprint !== request.expectedFingerprint) { + return { + outcome: 'target_conflict', + memberName: current?.name ?? request.memberName, + expectedFingerprint: request.expectedFingerprint, + actualFingerprint, + reason: current + ? 'target_changed' + : await this.dependencies.repository.classifyMissingTarget(request.teamName), + replayed: false, + }; + } + + const normalizedSettings = normalizeEditableMemberSettings(request.settings); + const proposed = { ...current, settings: normalizedSettings }; + const proposedFingerprint = createMemberSettingsFingerprint(proposed); + if (proposedFingerprint === actualFingerprint) { + return { + outcome: 'completed', + effect: 'no_changes', + memberName: current.name, + previousFingerprint: actualFingerprint, + currentFingerprint: actualFingerprint, + replayed: false, + }; + } + + const action = selectMemberSettingsLifecycleAction(current, proposed); + if (action === 'require_team_relaunch') { + return { + outcome: 'completed', + effect: 'team_relaunch_required', + memberName: current.name, + previousFingerprint: actualFingerprint, + currentFingerprint: actualFingerprint, + replayed: false, + }; + } + + let applied; + try { + applied = await this.dependencies.repository.applyTarget({ + teamName: request.teamName, + memberName: request.memberName, + expectedFingerprint: actualFingerprint, + settings: normalizedSettings, + }); + } catch (error) { + if (error instanceof MemberSettingsPersistenceFailedError && error.recoveryRequired) { + const latest = await this.dependencies.repository.findTarget( + request.teamName, + request.memberName + ); + return { + outcome: 'completed', + effect: 'recovery_required', + memberName: latest?.name ?? current.name, + previousFingerprint: actualFingerprint, + currentFingerprint: latest + ? createMemberSettingsFingerprint(latest) + : actualFingerprint, + replayed: false, + recovery: { + persistenceRestored: false, + lifecycleRestored: true, + cause: error.message, + }, + }; + } + throw error; + } + if (applied.outcome === 'target_conflict') { + return { + outcome: 'target_conflict', + memberName: applied.current?.name ?? request.memberName, + expectedFingerprint: actualFingerprint, + actualFingerprint: applied.current + ? createMemberSettingsFingerprint(applied.current) + : null, + reason: applied.current + ? 'target_changed' + : await this.dependencies.repository.classifyMissingTarget(request.teamName), + replayed: false, + }; + } + + const after = applied.snapshot; + const afterFingerprint = createMemberSettingsFingerprint(after); + if (action === 'none') { + return { + outcome: 'completed', + effect: 'persisted_only', + memberName: after.name, + previousFingerprint: actualFingerprint, + currentFingerprint: afterFingerprint, + replayed: false, + }; + } + + try { + const effect = await this.dependencies.lifecycle.applyEffect({ + teamName: request.teamName, + before: current, + after, + action, + }); + return { + outcome: 'completed', + effect, + memberName: after.name, + previousFingerprint: actualFingerprint, + currentFingerprint: afterFingerprint, + replayed: false, + }; + } catch (error) { + let persistenceRestored = false; + let lifecycleRestored = false; + try { + persistenceRestored = await this.dependencies.repository.restoreTarget({ + teamName: request.teamName, + memberName: after.name, + expectedFingerprint: afterFingerprint, + snapshot: current, + rollbackToken: applied.rollbackToken, + }); + } catch { + persistenceRestored = false; + } + if (persistenceRestored) { + try { + lifecycleRestored = await this.dependencies.lifecycle.restore({ + teamName: request.teamName, + before: current, + after, + attemptedAction: action, + }); + } catch { + lifecycleRestored = false; + } + } + + if (persistenceRestored && lifecycleRestored) { + throw error; + } + return { + outcome: 'completed', + effect: 'recovery_required', + memberName: after.name, + previousFingerprint: actualFingerprint, + currentFingerprint: persistenceRestored ? actualFingerprint : afterFingerprint, + replayed: false, + recovery: { + persistenceRestored, + lifecycleRestored, + cause: errorMessage(error), + }, + }; + } + }); + } catch (error) { + if (error instanceof MemberSettingsMutationBusyError) { + return { + outcome: 'busy', + teamName: request.teamName, + memberName: request.memberName, + replayed: false, + }; + } + throw error; + } + } +} diff --git a/src/features/team-provisioning/core/domain/memberSettingsPolicy.ts b/src/features/team-provisioning/core/domain/memberSettingsPolicy.ts new file mode 100644 index 0000000000..be82cfa31b --- /dev/null +++ b/src/features/team-provisioning/core/domain/memberSettingsPolicy.ts @@ -0,0 +1,139 @@ +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; + +import type { + EditableMemberSettings, + MemberSettingsProviderId, +} from '../../contracts/memberSettings'; + +export type MemberSettingsRuntimeLane = 'primary' | 'opencode_secondary'; + +export interface MemberSettingsTargetSnapshot { + name: string; + agentType: string | null; + agentId: string | null; + joinedAt: number | string | null; + settings: EditableMemberSettings; + teamIsAlive: boolean; + leadProviderId: MemberSettingsProviderId | null; + teamIsMixed: boolean; + runtimeLane: MemberSettingsRuntimeLane; +} + +export type MemberSettingsLifecycleAction = + | 'none' + | 'restart_member' + | 'restart_opencode_lane' + | 'require_team_relaunch'; + +const CANONICAL_LEAD_AGENT_TYPES = new Set(['team-lead', 'lead', 'orchestrator']); + +function normalizeText(value: string | null): string | null { + const normalized = value?.trim() ?? ''; + return normalized.length > 0 ? normalized : null; +} + +function normalizeIdentityText(value: string | null): string | null { + return normalizeText(value)?.toLowerCase() ?? null; +} + +function normalizeJoinedAt(value: number | string | null): string | null { + if (value === null) return null; + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : null; + const normalized = value.trim(); + if (!normalized) return null; + const numeric = Number(normalized); + return Number.isFinite(numeric) ? String(numeric) : normalized; +} + +function hasExactLegacyLeadRole(target: MemberSettingsTargetSnapshot): boolean { + return normalizeIdentityText(target.settings.role)?.replace(/\s+/g, ' ') === 'team lead'; +} + +export function normalizeEditableMemberSettings( + settings: EditableMemberSettings +): EditableMemberSettings { + const persistedMcpPolicy = normalizeTeamMemberMcpPolicy(settings.mcpPolicy); + const mcpPolicy = persistedMcpPolicy + ? { + ...persistedMcpPolicy, + ...(persistedMcpPolicy.serverNames + ? { + serverNames: [...persistedMcpPolicy.serverNames].sort((left, right) => + left.localeCompare(right) + ), + } + : {}), + } + : null; + + return { + role: normalizeText(settings.role), + workflow: normalizeText(settings.workflow), + isolation: settings.isolation, + providerId: settings.providerId, + providerBackendId: settings.providerBackendId, + model: normalizeText(settings.model), + effort: settings.effort, + fastMode: settings.fastMode, + mcpPolicy, + }; +} + +export function isCanonicalLeadTarget(target: MemberSettingsTargetSnapshot): boolean { + const agentType = normalizeIdentityText(target.agentType); + if (agentType && CANONICAL_LEAD_AGENT_TYPES.has(agentType)) { + return true; + } + if (normalizeIdentityText(target.name) === 'team-lead') { + return true; + } + if (agentType) return false; + + // Some old configs carry no canonical agentType. Retain only the exact + // normalized legacy role, never substring matches such as "tech team lead". + return hasExactLegacyLeadRole(target); +} + +export function createMemberSettingsFingerprint(target: MemberSettingsTargetSnapshot): string { + return JSON.stringify({ + memberName: normalizeIdentityText(target.name), + identity: { + agentId: normalizeText(target.agentId), + joinedAt: normalizeJoinedAt(target.joinedAt), + }, + settings: normalizeEditableMemberSettings(target.settings), + }); +} + +export function selectMemberSettingsLifecycleAction( + before: MemberSettingsTargetSnapshot, + proposed: MemberSettingsTargetSnapshot +): MemberSettingsLifecycleAction { + const promotesReservedLeadRole = + hasExactLegacyLeadRole(proposed) && !hasExactLegacyLeadRole(before); + if ( + isCanonicalLeadTarget(before) || + isCanonicalLeadTarget(proposed) || + promotesReservedLeadRole + ) { + return 'require_team_relaunch'; + } + if (!before.teamIsAlive) { + return 'none'; + } + if (before.leadProviderId === 'opencode') { + return 'require_team_relaunch'; + } + const ownedByOpenCodeBefore = before.settings.providerId === 'opencode'; + const ownedByOpenCodeAfter = proposed.settings.providerId === 'opencode'; + if (ownedByOpenCodeBefore !== ownedByOpenCodeAfter) { + return 'require_team_relaunch'; + } + if (before.teamIsMixed && !ownedByOpenCodeAfter) { + return 'require_team_relaunch'; + } + if (before.runtimeLane === 'opencode_secondary' && ownedByOpenCodeAfter) { + return 'restart_opencode_lane'; + } + return 'restart_member'; +} diff --git a/src/features/team-provisioning/main/adapters/input/registerTeamMemberSettingsIpc.ts b/src/features/team-provisioning/main/adapters/input/registerTeamMemberSettingsIpc.ts new file mode 100644 index 0000000000..244054c454 --- /dev/null +++ b/src/features/team-provisioning/main/adapters/input/registerTeamMemberSettingsIpc.ts @@ -0,0 +1,214 @@ +import { validateMemberName, validateTeamName } from '@main/ipc/guards'; + +import { TEAM_UPDATE_MEMBER_SETTINGS } from '../../../contracts'; + +import type { + EditableMemberSettings, + MemberSettingsMcpPolicy, + UpdateMemberSettingsRequest, + UpdateMemberSettingsResult, +} from '../../../contracts/memberSettings'; +import type { TeamMemberSettingsFeatureApi } from '../../composition/createTeamMemberSettingsFeature'; +import type { IpcResult } from '@shared/types'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +const SETTINGS_KEYS = [ + 'role', + 'workflow', + 'isolation', + 'providerId', + 'providerBackendId', + 'model', + 'effort', + 'fastMode', + 'mcpPolicy', +] as const; +const MAX_ID_LENGTH = 256; +const MAX_NAME_LENGTH = 128; +const MAX_TEXT_LENGTH = 16_384; +const MAX_FINGERPRINT_LENGTH = 65_536; +const MAX_MCP_SERVERS = 100; +const MAX_MCP_SERVER_NAME_LENGTH = 128; + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function requiredString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== 'string') throw new TypeError(`${field} must be a string`); + const normalized = value.trim(); + if (!normalized) throw new TypeError(`${field} must not be empty`); + if (normalized.length > maxLength) throw new TypeError(`${field} is too long`); + return normalized; +} + +function nullableText(value: unknown, field: string): string | null { + if (value === null) return null; + return requiredString(value, field, MAX_TEXT_LENGTH); +} + +function validatedIdentifier( + value: unknown, + field: 'teamName' | 'memberName', + validate: typeof validateTeamName | typeof validateMemberName +): string { + const raw = requiredString(value, field, MAX_NAME_LENGTH); + const result = validate(raw); + if (!result.valid || !result.value) { + throw new TypeError(result.error ?? `${field} is invalid`); + } + return result.value; +} + +function nullableUnion( + value: unknown, + field: string, + allowed: readonly T[] +): T | null { + if (value === null) return null; + if (typeof value !== 'string' || !allowed.includes(value as T)) { + throw new TypeError(`${field} has an unsupported value`); + } + return value as T; +} + +function parseMcpPolicy(value: unknown): MemberSettingsMcpPolicy | null { + if (value === null) return null; + if (!isPlainObject(value)) throw new TypeError('settings.mcpPolicy must be an object or null'); + const unknownPolicyKey = Object.keys(value).find( + (key) => key !== 'mode' && key !== 'scopes' && key !== 'serverNames' + ); + if (unknownPolicyKey) throw new TypeError(`Unsupported MCP policy field: ${unknownPolicyKey}`); + const mode = nullableUnion(value.mode, 'settings.mcpPolicy.mode', [ + 'inheritLead', + 'inheritScopes', + 'strictAllowlist', + 'appOnly', + ] as const); + if (!mode) throw new TypeError('settings.mcpPolicy.mode is required'); + + let scopes: MemberSettingsMcpPolicy['scopes']; + if (value.scopes !== undefined) { + if (!isPlainObject(value.scopes)) { + throw new TypeError('settings.mcpPolicy.scopes must be an object'); + } + const unknownScope = Object.keys(value.scopes).find( + (key) => key !== 'user' && key !== 'project' && key !== 'local' + ); + if (unknownScope) throw new TypeError(`Unsupported MCP scope: ${unknownScope}`); + for (const [key, enabled] of Object.entries(value.scopes)) { + if (typeof enabled !== 'boolean') { + throw new TypeError(`settings.mcpPolicy.scopes.${key} must be boolean`); + } + } + scopes = { ...value.scopes }; + } + + let serverNames: string[] | undefined; + if (value.serverNames !== undefined) { + if (!Array.isArray(value.serverNames) || value.serverNames.length > MAX_MCP_SERVERS) { + throw new TypeError('settings.mcpPolicy.serverNames must be a bounded array'); + } + const seen = new Set(); + serverNames = value.serverNames + .map((name, index) => + requiredString(name, `settings.mcpPolicy.serverNames[${index}]`, MAX_MCP_SERVER_NAME_LENGTH) + ) + .filter((name) => { + const key = name.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort(); + } + return { mode, ...(scopes ? { scopes } : {}), ...(serverNames ? { serverNames } : {}) }; +} + +function parseSettings(value: unknown): EditableMemberSettings { + if (!isPlainObject(value)) throw new TypeError('settings must be an object'); + for (const key of SETTINGS_KEYS) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + throw new TypeError(`settings.${key} is required; use null to clear it`); + } + } + const unknownKey = Object.keys(value).find( + (key) => !SETTINGS_KEYS.includes(key as (typeof SETTINGS_KEYS)[number]) + ); + if (unknownKey) throw new TypeError(`Unsupported settings field: ${unknownKey}`); + + return { + role: nullableText(value.role, 'settings.role'), + workflow: nullableText(value.workflow, 'settings.workflow'), + isolation: nullableUnion(value.isolation, 'settings.isolation', ['worktree'] as const), + providerId: nullableUnion(value.providerId, 'settings.providerId', [ + 'anthropic', + 'codex', + 'gemini', + 'opencode', + ] as const), + providerBackendId: nullableUnion(value.providerBackendId, 'settings.providerBackendId', [ + 'auto', + 'adapter', + 'api', + 'cli-sdk', + 'codex-native', + 'opencode-cli', + ] as const), + model: nullableText(value.model, 'settings.model'), + effort: nullableUnion(value.effort, 'settings.effort', [ + 'none', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'ultra', + ] as const), + fastMode: nullableUnion(value.fastMode, 'settings.fastMode', ['inherit', 'on', 'off'] as const), + mcpPolicy: parseMcpPolicy(value.mcpPolicy), + }; +} + +export function parseUpdateMemberSettingsRequest(value: unknown): UpdateMemberSettingsRequest { + if (!isPlainObject(value)) throw new TypeError('request must be a plain object'); + return { + commandId: requiredString(value.commandId, 'commandId', MAX_ID_LENGTH), + idempotencyKey: requiredString(value.idempotencyKey, 'idempotencyKey', MAX_ID_LENGTH), + teamName: validatedIdentifier(value.teamName, 'teamName', validateTeamName), + memberName: validatedIdentifier(value.memberName, 'memberName', validateMemberName), + expectedFingerprint: requiredString( + value.expectedFingerprint, + 'expectedFingerprint', + MAX_FINGERPRINT_LENGTH + ), + settings: parseSettings(value.settings), + }; +} + +export function registerTeamMemberSettingsIpc( + ipcMain: IpcMain, + feature: TeamMemberSettingsFeatureApi +): void { + ipcMain.handle( + TEAM_UPDATE_MEMBER_SETTINGS, + async ( + _event: IpcMainInvokeEvent, + value: unknown + ): Promise> => { + try { + const data = await feature.updateMemberSettings(parseUpdateMemberSettingsRequest(value)); + return { success: true, data }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + ); +} + +export function removeTeamMemberSettingsIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(TEAM_UPDATE_MEMBER_SETTINGS); +} diff --git a/src/features/team-provisioning/main/adapters/output/LegacyMemberSettingsLifecycleAdapter.ts b/src/features/team-provisioning/main/adapters/output/LegacyMemberSettingsLifecycleAdapter.ts new file mode 100644 index 0000000000..01712ec315 --- /dev/null +++ b/src/features/team-provisioning/main/adapters/output/LegacyMemberSettingsLifecycleAdapter.ts @@ -0,0 +1,51 @@ +import type { + MemberSettingsLifecycleEffect, + MemberSettingsLifecyclePort, +} from '../../../core/application/ports/UpdateMemberSettingsPorts'; + +export interface LegacyMemberSettingsLifecycleSource { + attachLiveRosterMember( + teamName: string, + memberName: string, + options: { reason: 'member_updated' } + ): Promise; + isTeamAlive(teamName: string): boolean; +} + +/** Keeps provider/runtime mechanics behind the existing focused attach operation. */ +export class LegacyMemberSettingsLifecycleAdapter implements MemberSettingsLifecyclePort { + constructor(private readonly source: LegacyMemberSettingsLifecycleSource) {} + + async applyEffect( + input: Parameters[0] + ): Promise { + if (input.action === 'require_team_relaunch') { + throw new Error('Team relaunch must be initiated by an explicit relaunch command'); + } + if (!this.source.isTeamAlive(input.teamName)) { + return 'persisted_only'; + } + + await this.source.attachLiveRosterMember(input.teamName, input.after.name, { + reason: 'member_updated', + }); + return input.action === 'restart_opencode_lane' + ? 'opencode_lane_restart_started' + : 'member_restart_started'; + } + + // eslint-disable-next-line sonarjs/no-invariant-returns -- Successful completion is the factual rollback result; failures throw. + async restore(input: Parameters[0]): Promise { + if (input.attemptedAction === 'require_team_relaunch') { + return true; + } + if (!this.source.isTeamAlive(input.teamName)) { + return true; + } + + await this.source.attachLiveRosterMember(input.teamName, input.before.name, { + reason: 'member_updated', + }); + return true; + } +} diff --git a/src/features/team-provisioning/main/adapters/output/LegacyMemberSettingsMutationGateAdapter.ts b/src/features/team-provisioning/main/adapters/output/LegacyMemberSettingsMutationGateAdapter.ts new file mode 100644 index 0000000000..6de9754354 --- /dev/null +++ b/src/features/team-provisioning/main/adapters/output/LegacyMemberSettingsMutationGateAdapter.ts @@ -0,0 +1,32 @@ +import { MemberSettingsMutationBusyError } from '../../../core/application/ports/UpdateMemberSettingsPorts'; + +import type { MemberSettingsMutationGatePort } from '../../../core/application/ports/UpdateMemberSettingsPorts'; + +export interface LegacyLiveRosterMutationSource { + runLiveRosterMutation(teamName: string, mutation: () => Promise): Promise; + tryRunLiveRosterMutation?(teamName: string, mutation: () => Promise): Promise; +} + +/** Adapts the legacy roster lock without exposing the lifecycle service to the use case. */ +export class LegacyMemberSettingsMutationGateAdapter implements MemberSettingsMutationGatePort { + constructor(private readonly source: LegacyLiveRosterMutationSource) {} + + async runExclusive(teamName: string, operation: () => Promise): Promise { + let completed = false; + let result!: T; + const mutation = async (): Promise => { + result = await operation(); + completed = true; + }; + if (this.source.tryRunLiveRosterMutation) { + const acquired = await this.source.tryRunLiveRosterMutation(teamName, mutation); + if (!acquired) throw new MemberSettingsMutationBusyError(teamName); + } else { + await this.source.runLiveRosterMutation(teamName, mutation); + } + if (!completed) { + throw new Error('Live roster mutation gate completed without running its callback'); + } + return result; + } +} diff --git a/src/features/team-provisioning/main/adapters/output/LegacyMemberSettingsRepositoryAdapter.ts b/src/features/team-provisioning/main/adapters/output/LegacyMemberSettingsRepositoryAdapter.ts new file mode 100644 index 0000000000..d775dfb235 --- /dev/null +++ b/src/features/team-provisioning/main/adapters/output/LegacyMemberSettingsRepositoryAdapter.ts @@ -0,0 +1,553 @@ +import { fromProvisioningMembers, isMixedOpenCodeSideLanePlan } from '@features/team-runtime-lanes'; +import { atomicWriteAsync } from '@main/services/team/atomicWrite'; +import { withFileLock } from '@main/services/team/fileLock'; +import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; +import { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; +import { TeamMetaStore } from '@main/services/team/TeamMetaStore'; +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy'; +import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { MemberSettingsPersistenceFailedError } from '../../../core/application/ports/UpdateMemberSettingsPorts'; +import { createMemberSettingsFingerprint } from '../../../core/domain/memberSettingsPolicy'; + +import type { + EditableMemberSettings, + MemberSettingsEffort, + MemberSettingsFastMode, + MemberSettingsProviderBackendId, + MemberSettingsProviderId, +} from '../../../contracts/memberSettings'; +import type { + ApplyMemberSettingsResult, + MemberSettingsRepositoryPort, +} from '../../../core/application/ports/UpdateMemberSettingsPorts'; +import type { MemberSettingsTargetSnapshot } from '../../../core/domain/memberSettingsPolicy'; +import type { TeamMembersMetaFile } from '@main/services/team/TeamMembersMetaStore'; +import type { TeamMember } from '@shared/types'; + +type JsonMember = Record & { name?: unknown; removedAt?: unknown }; +type JsonTeamConfig = Record & { members?: JsonMember[] }; + +export interface LegacyMemberSettingsRepositoryDependencies { + membersMetaStore: Pick; + readConfigJson(teamName: string): Promise; + writeConfigJsonAtomic(teamName: string, contents: string): Promise; + withConfigLock(teamName: string, operation: () => Promise): Promise; + readLeadProviderId(teamName: string): Promise; + teamExists(teamName: string): Promise; + isTeamAlive(teamName: string): boolean | Promise; + invalidateCaches(teamName: string): void; +} + +interface LoadedTarget { + config: JsonTeamConfig | null; + configMember: JsonMember | null; + configMemberIndex: number; + meta: TeamMembersMetaFile | null; + metaMember: TeamMember | null; + metaMemberIndex: number; + snapshot: MemberSettingsTargetSnapshot; +} + +interface LegacyTargetRollbackToken { + kind: 'legacy-member-settings-v1'; + memberName: string; + metadataMember: TeamMember | null; + configMember: JsonMember | null; +} + +const SETTINGS_FIELDS = [ + 'role', + 'workflow', + 'isolation', + 'providerId', + 'providerBackendId', + 'model', + 'effort', + 'fastMode', + 'mcpPolicy', +] as const; + +function matchesName(candidate: unknown, requested: unknown): boolean { + return ( + typeof candidate === 'string' && + typeof requested === 'string' && + candidate.trim().toLowerCase() === requested.trim().toLowerCase() + ); +} + +function isRemovedMember(member: { removedAt?: unknown } | null): boolean { + return member?.removedAt !== undefined && member.removedAt !== null; +} + +function optionalText(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function providerId(value: unknown): MemberSettingsProviderId | null { + return normalizeOptionalTeamProviderId(value) ?? null; +} + +function backendId(value: unknown): MemberSettingsProviderBackendId | null { + return value === 'auto' || + value === 'adapter' || + value === 'api' || + value === 'cli-sdk' || + value === 'codex-native' || + value === 'opencode-cli' + ? value + : null; +} + +function effort(value: unknown): MemberSettingsEffort | null { + return value === 'none' || + value === 'minimal' || + value === 'low' || + value === 'medium' || + value === 'high' || + value === 'xhigh' || + value === 'max' || + value === 'ultra' + ? value + : null; +} + +function fastMode(value: unknown): MemberSettingsFastMode | null { + return value === 'inherit' || value === 'on' || value === 'off' ? value : null; +} + +function preferred(meta: TeamMember | null, config: JsonMember | null, field: string): unknown { + const configValue = config?.[field]; + return configValue !== undefined ? configValue : meta?.[field as keyof TeamMember]; +} + +function readSettings(meta: TeamMember | null, config: JsonMember | null): EditableMemberSettings { + const rawPolicy = preferred(meta, config, 'mcpPolicy'); + return { + role: optionalText(preferred(meta, config, 'role')), + workflow: optionalText(preferred(meta, config, 'workflow')), + isolation: preferred(meta, config, 'isolation') === 'worktree' ? 'worktree' : null, + providerId: providerId(config?.providerId ?? config?.provider ?? meta?.providerId), + providerBackendId: backendId(preferred(meta, config, 'providerBackendId')), + model: optionalText(preferred(meta, config, 'model')), + effort: effort(preferred(meta, config, 'effort')), + fastMode: fastMode(preferred(meta, config, 'fastMode')), + mcpPolicy: + rawPolicy && typeof rawPolicy === 'object' + ? (normalizeTeamMemberMcpPolicy(rawPolicy) ?? null) + : null, + }; +} + +function setOptional(target: Record, key: string, value: unknown): void { + if (value === null) { + delete target[key]; + } else { + target[key] = structuredClone(value); + } +} + +function applySettings( + member: object, + settings: EditableMemberSettings, + includeLegacyProvider: boolean +): Record { + const next: Record = { ...member }; + for (const field of SETTINGS_FIELDS) { + setOptional(next, field, settings[field]); + } + if (includeLegacyProvider) { + setOptional(next, 'provider', settings.providerId); + } + return next; +} + +function rollbackToken(current: LoadedTarget): LegacyTargetRollbackToken { + return { + kind: 'legacy-member-settings-v1', + memberName: current.snapshot.name, + metadataMember: current.metaMember ? structuredClone(current.metaMember) : null, + configMember: current.configMember ? structuredClone(current.configMember) : null, + }; +} + +function isRollbackToken(value: unknown): value is LegacyTargetRollbackToken { + return ( + Boolean(value) && + typeof value === 'object' && + (value as { kind?: unknown }).kind === 'legacy-member-settings-v1' + ); +} + +function parseConfig(raw: string | null): JsonTeamConfig | null { + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') return null; + const config = parsed as JsonTeamConfig; + if (config.members !== undefined && !Array.isArray(config.members)) { + throw new Error('Team config members must be an array'); + } + return { ...config, members: config.members ?? [] }; +} + +/** Target-scoped adapter over legacy members.meta.json and config.json persistence. */ +export class LegacyMemberSettingsRepositoryAdapter implements MemberSettingsRepositoryPort { + constructor(private readonly dependencies: LegacyMemberSettingsRepositoryDependencies) {} + + async findTarget( + teamName: string, + memberName: string + ): Promise { + return (await this.loadTarget(teamName, memberName))?.snapshot ?? null; + } + + async classifyMissingTarget(teamName: string): Promise<'member_not_found' | 'team_not_found'> { + return (await this.dependencies.teamExists(teamName)) ? 'member_not_found' : 'team_not_found'; + } + + async applyTarget(input: { + teamName: string; + memberName: string; + expectedFingerprint: string; + settings: EditableMemberSettings; + }): Promise { + return this.dependencies.withConfigLock(input.teamName, async () => { + const current = await this.loadTarget(input.teamName, input.memberName); + if ( + !current || + createMemberSettingsFingerprint(current.snapshot) !== input.expectedFingerprint + ) { + return { outcome: 'target_conflict', current: current?.snapshot ?? null }; + } + + const previous = rollbackToken(current); + await this.persistSettings(current, input.teamName, input.settings); + try { + const persisted = await this.loadTarget(input.teamName, current.snapshot.name); + if (!persisted) { + throw new Error(`Updated member disappeared from persistence: ${current.snapshot.name}`); + } + return { outcome: 'applied', snapshot: persisted.snapshot, rollbackToken: previous }; + } catch (error) { + try { + await this.restoreLoadedTarget(input.teamName, current); + } catch (rollbackError) { + throw new MemberSettingsPersistenceFailedError( + `Target persistence and rollback failed for ${input.teamName}/${current.snapshot.name}`, + true, + new AggregateError([error, rollbackError]) + ); + } + throw new MemberSettingsPersistenceFailedError( + `Target persistence failed for ${input.teamName}/${current.snapshot.name}`, + false, + error + ); + } + }); + } + + async restoreTarget(input: { + teamName: string; + memberName: string; + expectedFingerprint: string; + snapshot: MemberSettingsTargetSnapshot; + rollbackToken: unknown; + }): Promise { + return this.dependencies.withConfigLock(input.teamName, async () => { + const current = await this.loadTarget(input.teamName, input.memberName); + if ( + !current || + createMemberSettingsFingerprint(current.snapshot) !== input.expectedFingerprint + ) { + return false; + } + if (!isRollbackToken(input.rollbackToken)) return false; + await this.restoreRawTarget(input.teamName, current, input.rollbackToken); + return true; + }); + } + + private async loadTarget(teamName: string, memberName: string): Promise { + const [meta, rawConfig] = await Promise.all([ + this.dependencies.membersMetaStore.getMeta(teamName), + this.dependencies.readConfigJson(teamName), + ]); + const config = parseConfig(rawConfig); + const configMemberIndex = + config?.members?.findIndex((member) => matchesName(member.name, memberName)) ?? -1; + const configMember = + configMemberIndex >= 0 ? (config?.members?.[configMemberIndex] ?? null) : null; + const metaMemberIndex = + meta?.members.findIndex((member) => matchesName(member.name, memberName)) ?? -1; + const metaMember = metaMemberIndex >= 0 ? (meta?.members[metaMemberIndex] ?? null) : null; + if (isRemovedMember(metaMember) || isRemovedMember(configMember)) return null; + if (!metaMember && !configMember) return null; + const snapshot = await this.buildSnapshot(teamName, metaMember, configMember, config, meta); + return { + config, + configMember, + configMemberIndex, + meta, + metaMember, + metaMemberIndex, + snapshot, + }; + } + + private async buildSnapshot( + teamName: string, + metaMember: TeamMember | null, + configMember: JsonMember | null, + config: JsonTeamConfig | null, + meta: TeamMembersMetaFile | null + ): Promise { + const leadProviderId = await this.dependencies.readLeadProviderId(teamName); + const effectiveByName = new Map< + string, + { name: string; providerId?: MemberSettingsProviderId } + >(); + const removedNames = new Set(); + for (const member of [...(config?.members ?? []), ...(meta?.members ?? [])]) { + if (isRemovedMember(member) && typeof member.name === 'string') { + removedNames.add(member.name.trim().toLowerCase()); + } + } + for (const member of config?.members ?? []) { + if (typeof member.name !== 'string' || !member.name.trim()) continue; + const key = member.name.trim().toLowerCase(); + if (removedNames.has(key)) continue; + effectiveByName.set(key, { + name: member.name.trim(), + providerId: providerId(member.providerId ?? member.provider) ?? undefined, + }); + } + for (const member of meta?.members ?? []) { + const key = member.name.trim().toLowerCase(); + if (removedNames.has(key)) continue; + effectiveByName.set(key, { + name: member.name, + providerId: + effectiveByName.get(key)?.providerId ?? providerId(member.providerId) ?? undefined, + }); + } + const effectiveMembers = Array.from(effectiveByName.values()); + const plan = fromProvisioningMembers(leadProviderId ?? undefined, effectiveMembers); + const targetProviderId = providerId( + configMember?.providerId ?? configMember?.provider ?? metaMember?.providerId + ); + const runtimeLane = + leadProviderId !== 'opencode' && targetProviderId === 'opencode' + ? 'opencode_secondary' + : 'primary'; + + return { + name: + typeof configMember?.name === 'string' + ? configMember.name.trim() + : (metaMember?.name ?? ''), + agentType: optionalText(configMember?.agentType ?? metaMember?.agentType), + agentId: optionalText(configMember?.agentId ?? metaMember?.agentId), + joinedAt: + typeof (configMember?.joinedAt ?? metaMember?.joinedAt) === 'number' || + typeof (configMember?.joinedAt ?? metaMember?.joinedAt) === 'string' + ? ((configMember?.joinedAt ?? metaMember?.joinedAt) as number | string) + : null, + settings: readSettings(metaMember, configMember), + teamIsAlive: await this.dependencies.isTeamAlive(teamName), + leadProviderId, + teamIsMixed: plan.ok && isMixedOpenCodeSideLanePlan(plan.plan), + runtimeLane, + }; + } + + private async persistSettings( + current: LoadedTarget, + teamName: string, + settings: EditableMemberSettings + ): Promise { + const previousMetaMember = current.metaMember ? { ...current.metaMember } : null; + const metaMembers = [...(current.meta?.members ?? [])]; + const nextMetaMember = applySettings( + current.metaMember ?? { name: current.snapshot.name }, + settings, + false + ) as unknown as TeamMember; + if (current.metaMemberIndex >= 0) metaMembers[current.metaMemberIndex] = nextMetaMember; + else metaMembers.push(nextMetaMember); + + await this.dependencies.membersMetaStore.writeMembers(teamName, metaMembers, { + providerBackendId: current.meta?.providerBackendId, + }); + + if (!current.config) { + this.dependencies.invalidateCaches(teamName); + return; + } + const nextConfig = { ...current.config, members: [...(current.config.members ?? [])] }; + const nextConfigMember = applySettings( + current.configMember ?? { name: current.snapshot.name }, + settings, + true + ); + if (current.configMemberIndex >= 0) { + nextConfig.members[current.configMemberIndex] = nextConfigMember; + } else { + nextConfig.members.push(nextConfigMember); + } + try { + await this.dependencies.writeConfigJsonAtomic( + teamName, + `${JSON.stringify(nextConfig, null, 2)}\n` + ); + } catch (error) { + try { + await this.rollbackMetadataTarget(teamName, current.snapshot.name, previousMetaMember); + this.dependencies.invalidateCaches(teamName); + } catch (rollbackError) { + try { + this.dependencies.invalidateCaches(teamName); + } catch { + // The recovery result below already prevents automatic retry. + } + throw new MemberSettingsPersistenceFailedError( + `Config update and target metadata rollback failed for ${teamName}/${current.snapshot.name}`, + true, + new AggregateError([error, rollbackError]) + ); + } + throw new MemberSettingsPersistenceFailedError( + `Config update failed for ${teamName}/${current.snapshot.name}`, + false, + error + ); + } + this.dependencies.invalidateCaches(teamName); + } + + private async restoreLoadedTarget(teamName: string, previous: LoadedTarget): Promise { + const latest = await this.loadTarget(teamName, previous.snapshot.name); + if (!latest) { + throw new Error(`Cannot restore missing target ${teamName}/${previous.snapshot.name}`); + } + await this.restoreRawTarget(teamName, latest, rollbackToken(previous)); + } + + private async restoreRawTarget( + teamName: string, + current: LoadedTarget, + previous: LegacyTargetRollbackToken + ): Promise { + const currentMetadataMember = current.metaMember ? structuredClone(current.metaMember) : null; + const metadataMembers = [...(current.meta?.members ?? [])]; + if (previous.metadataMember) { + if (current.metaMemberIndex >= 0) + metadataMembers[current.metaMemberIndex] = previous.metadataMember; + else metadataMembers.push(previous.metadataMember); + } else if (current.metaMemberIndex >= 0) { + metadataMembers.splice(current.metaMemberIndex, 1); + } + await this.dependencies.membersMetaStore.writeMembers(teamName, metadataMembers, { + providerBackendId: current.meta?.providerBackendId, + }); + + if (!current.config) { + if (previous.configMember) { + await this.rollbackMetadataTarget(teamName, previous.memberName, currentMetadataMember); + throw new Error(`Cannot restore missing config target ${teamName}/${previous.memberName}`); + } + this.dependencies.invalidateCaches(teamName); + return; + } + const nextConfig = { ...current.config, members: [...(current.config.members ?? [])] }; + if (previous.configMember) { + if (current.configMemberIndex >= 0) + nextConfig.members[current.configMemberIndex] = previous.configMember; + else nextConfig.members.push(previous.configMember); + } else if (current.configMemberIndex >= 0) { + nextConfig.members.splice(current.configMemberIndex, 1); + } + try { + await this.dependencies.writeConfigJsonAtomic( + teamName, + `${JSON.stringify(nextConfig, null, 2)}\n` + ); + } catch (error) { + try { + await this.rollbackMetadataTarget(teamName, previous.memberName, currentMetadataMember); + } finally { + this.dependencies.invalidateCaches(teamName); + } + throw error; + } + this.dependencies.invalidateCaches(teamName); + } + + private async rollbackMetadataTarget( + teamName: string, + memberName: string, + previous: TeamMember | null + ): Promise { + const latest = await this.dependencies.membersMetaStore.getMeta(teamName); + const members = [...(latest?.members ?? [])]; + const index = members.findIndex((member) => matchesName(member.name, memberName)); + if (previous) { + if (index >= 0) members[index] = previous; + else members.push(previous); + } else if (index >= 0) { + members.splice(index, 1); + } + await this.dependencies.membersMetaStore.writeMembers(teamName, members, { + providerBackendId: latest?.providerBackendId, + }); + } +} + +export interface NodeLegacyMemberSettingsRepositoryOptions { + isTeamAlive(teamName: string): boolean; + invalidateWorkerCache(teamName: string): void; +} + +export function createNodeLegacyMemberSettingsRepositoryDependencies( + options: NodeLegacyMemberSettingsRepositoryOptions +): LegacyMemberSettingsRepositoryDependencies { + const membersMetaStore = new TeamMembersMetaStore(); + const teamMetaStore = new TeamMetaStore(); + return { + membersMetaStore, + async readConfigJson(teamName) { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + return await fs.promises.readFile(configPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + }, + writeConfigJsonAtomic(teamName, contents) { + return atomicWriteAsync(path.join(getTeamsBasePath(), teamName, 'config.json'), contents); + }, + withConfigLock(teamName, operation) { + return withFileLock(path.join(getTeamsBasePath(), teamName, 'config.json'), operation); + }, + async readLeadProviderId(teamName) { + return (await teamMetaStore.getMeta(teamName))?.providerId ?? null; + }, + async teamExists(teamName) { + try { + return (await fs.promises.stat(path.join(getTeamsBasePath(), teamName))).isDirectory(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + }, + isTeamAlive: options.isTeamAlive, + invalidateCaches(teamName) { + TeamConfigReader.invalidateTeam(teamName); + options.invalidateWorkerCache(teamName); + }, + }; +} diff --git a/src/features/team-provisioning/main/composition/createTeamMemberSettingsFeature.ts b/src/features/team-provisioning/main/composition/createTeamMemberSettingsFeature.ts new file mode 100644 index 0000000000..9bf8bd7648 --- /dev/null +++ b/src/features/team-provisioning/main/composition/createTeamMemberSettingsFeature.ts @@ -0,0 +1,314 @@ +import { + ApplicationCommandFailureKind, + type ApplicationCommandJsonValue, + type ApplicationCommandRunner, + ApplicationCommandRunOutcome, +} from '@features/application-command-ledger'; + +import { MemberSettingsPersistenceFailedError } from '../../core/application/ports/UpdateMemberSettingsPorts'; +import { UpdateMemberSettingsUseCase } from '../../core/application/use-cases/UpdateMemberSettingsUseCase'; +import { + createMemberSettingsFingerprint, + normalizeEditableMemberSettings, +} from '../../core/domain/memberSettingsPolicy'; +import { LegacyMemberSettingsLifecycleAdapter } from '../adapters/output/LegacyMemberSettingsLifecycleAdapter'; +import { LegacyMemberSettingsMutationGateAdapter } from '../adapters/output/LegacyMemberSettingsMutationGateAdapter'; +import { LegacyMemberSettingsRepositoryAdapter } from '../adapters/output/LegacyMemberSettingsRepositoryAdapter'; +import { createNodeLegacyMemberSettingsRepositoryDependencies } from '../adapters/output/LegacyMemberSettingsRepositoryAdapter'; + +import type { + UpdateMemberSettingsRequest, + UpdateMemberSettingsResult, +} from '../../contracts/memberSettings'; +import type { MemberSettingsRepositoryPort } from '../../core/application/ports/UpdateMemberSettingsPorts'; +import type { LegacyMemberSettingsLifecycleSource } from '../adapters/output/LegacyMemberSettingsLifecycleAdapter'; +import type { LegacyLiveRosterMutationSource } from '../adapters/output/LegacyMemberSettingsMutationGateAdapter'; +import type { LegacyMemberSettingsRepositoryDependencies } from '../adapters/output/LegacyMemberSettingsRepositoryAdapter'; + +interface NodeMemberSettingsRuntimeSource { + isTeamAlive(teamName: string): boolean; +} + +interface NodeMemberSettingsCacheSource { + invalidateTeamConfig(teamName: string): void; + invalidateMemberRuntimeAdvisory(teamName: string): void; +} + +const COMMAND_NAMESPACE = 'team-member-settings'; +const COMMAND_OPERATION = 'update_member_settings'; +const MAX_IN_PROCESS_COMMANDS = 4_096; + +export interface TeamMemberSettingsFeatureApi { + updateMemberSettings(request: UpdateMemberSettingsRequest): Promise; +} + +export interface TeamMemberSettingsFeatureDependencies { + mutationSource: LegacyLiveRosterMutationSource; + lifecycleSource: LegacyMemberSettingsLifecycleSource; + repositoryDependencies?: LegacyMemberSettingsRepositoryDependencies; + repository?: MemberSettingsRepositoryPort; + commandRunner?: ApplicationCommandRunner | null; +} + +export interface NodeTeamMemberSettingsFeatureDependencies { + commandRunner?: ApplicationCommandRunner | null; + memberLifecycle: LegacyLiveRosterMutationSource & + Pick; + runtime: NodeMemberSettingsRuntimeSource; + getWorkerCache(): NodeMemberSettingsCacheSource; +} + +interface InProcessCommandEntry { + commandKey: string; + idempotencyKey: string; + payload: string; + promise: Promise | null; + state: 'idle' | 'in_flight' | 'completed'; +} + +function withReplay( + result: UpdateMemberSettingsResult, + replayed: boolean +): UpdateMemberSettingsResult { + return { ...result, replayed }; +} + +function recoveryRequiredResult( + request: UpdateMemberSettingsRequest, + currentFingerprint: string | null, + cause: string +): UpdateMemberSettingsResult { + return { + outcome: 'completed', + effect: 'recovery_required', + memberName: request.memberName, + previousFingerprint: request.expectedFingerprint, + currentFingerprint: currentFingerprint ?? request.expectedFingerprint, + replayed: false, + recovery: { persistenceRestored: false, lifecycleRestored: false, cause }, + }; +} + +function normalizeScopePart(value: string): string { + return value.trim().toLowerCase(); +} + +function commandScope(request: UpdateMemberSettingsRequest): string { + return `${normalizeScopePart(request.teamName)}/${normalizeScopePart(request.memberName)}`; +} + +function jsonValue(value: unknown): ApplicationCommandJsonValue { + return JSON.parse(JSON.stringify(value)) as ApplicationCommandJsonValue; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +class InProcessMemberSettingsCommandRunner { + private readonly byCommandId = new Map(); + private readonly byIdempotencyKey = new Map(); + private readonly settledEntries = new Set(); + + run( + request: UpdateMemberSettingsRequest, + execute: () => Promise + ): Promise { + const scope = commandScope(request); + const commandKey = `${scope}\u0000${request.commandId}`; + const idempotencyKey = `${scope}\u0000${request.idempotencyKey}`; + const payload = JSON.stringify(jsonValue(request)); + const commandEntry = this.byCommandId.get(commandKey); + const idempotencyEntry = this.byIdempotencyKey.get(idempotencyKey); + if (commandEntry && idempotencyEntry && commandEntry !== idempotencyEntry) { + return Promise.reject(new Error('Member settings command identities do not match')); + } + const existing = commandEntry ?? idempotencyEntry; + if (existing) { + if ( + existing.commandKey !== commandKey || + existing.idempotencyKey !== idempotencyKey || + existing.payload !== payload + ) { + return Promise.reject(new Error('Member settings command identity was reused')); + } + if (existing.promise) { + return existing.promise.then((result) => withReplay(result, true)); + } + return this.execute(existing, execute); + } + + this.evictSettledEntriesForAdmission(); + if (this.byCommandId.size >= MAX_IN_PROCESS_COMMANDS) { + return Promise.reject(new Error('In-process member settings command capacity was reached')); + } + const entry: InProcessCommandEntry = { + commandKey, + idempotencyKey, + payload, + promise: null, + state: 'idle', + }; + this.byCommandId.set(commandKey, entry); + this.byIdempotencyKey.set(idempotencyKey, entry); + return this.execute(entry, execute); + } + + private execute( + entry: InProcessCommandEntry, + execute: () => Promise + ): Promise { + this.settledEntries.delete(entry); + entry.state = 'in_flight'; + const promise = Promise.resolve().then(execute); + entry.promise = promise; + void promise.then( + () => { + if (entry.promise !== promise) return; + entry.state = 'completed'; + this.settledEntries.add(entry); + }, + () => { + if (entry.promise !== promise) return; + entry.promise = null; + entry.state = 'idle'; + this.settledEntries.add(entry); + } + ); + return promise; + } + + private evictSettledEntriesForAdmission(): void { + while (this.byCommandId.size >= MAX_IN_PROCESS_COMMANDS) { + const oldest = this.settledEntries.values().next().value; + if (!oldest) return; + this.settledEntries.delete(oldest); + if (oldest.state === 'in_flight') continue; + if (this.byCommandId.get(oldest.commandKey) === oldest) { + this.byCommandId.delete(oldest.commandKey); + } + if (this.byIdempotencyKey.get(oldest.idempotencyKey) === oldest) { + this.byIdempotencyKey.delete(oldest.idempotencyKey); + } + } + } +} + +/** Wires the focused ports and adds durable (or process-local) command idempotency. */ +export function createTeamMemberSettingsFeature( + dependencies: TeamMemberSettingsFeatureDependencies +): TeamMemberSettingsFeatureApi { + const repository = + dependencies.repository ?? + (dependencies.repositoryDependencies + ? new LegacyMemberSettingsRepositoryAdapter(dependencies.repositoryDependencies) + : null); + if (!repository) { + throw new Error('Team member settings feature requires a repository'); + } + + const useCase = new UpdateMemberSettingsUseCase({ + mutationGate: new LegacyMemberSettingsMutationGateAdapter(dependencies.mutationSource), + repository, + lifecycle: new LegacyMemberSettingsLifecycleAdapter(dependencies.lifecycleSource), + }); + const fallback = new InProcessMemberSettingsCommandRunner(); + + return { + async updateMemberSettings(request) { + const execute = () => useCase.execute(request); + if (!dependencies.commandRunner) { + return fallback.run(request, execute); + } + + const normalizedSettings = normalizeEditableMemberSettings(request.settings); + const proposed = await repository.findTarget(request.teamName, request.memberName); + const proposedFingerprint = proposed + ? createMemberSettingsFingerprint({ ...proposed, settings: normalizedSettings }) + : null; + const run = await dependencies.commandRunner.run< + ApplicationCommandJsonValue, + typeof COMMAND_OPERATION + >( + { + namespace: COMMAND_NAMESPACE, + scopeKey: commandScope(request), + commandId: request.commandId, + idempotencyKey: request.idempotencyKey, + operation: COMMAND_OPERATION, + payload: jsonValue(request), + classifyError: (error) => ({ + failureKind: + error instanceof MemberSettingsPersistenceFailedError + ? error.recoveryRequired + ? ApplicationCommandFailureKind.Terminal + : ApplicationCommandFailureKind.Retryable + : ApplicationCommandFailureKind.UnknownAfterTimeout, + message: describeError(error), + }), + reconcile: async () => { + const current = await repository.findTarget(request.teamName, request.memberName); + const currentFingerprint = current ? createMemberSettingsFingerprint(current) : null; + if (current && proposedFingerprint && currentFingerprint === proposedFingerprint) { + return { + outcome: 'applied', + result: jsonValue( + recoveryRequiredResult( + request, + currentFingerprint, + 'Settings were persisted, but runtime lifecycle completion cannot be proven' + ) + ), + }; + } + if (currentFingerprint === request.expectedFingerprint) { + return { + outcome: 'not_applied', + message: 'Target settings still match the pre-command fingerprint', + }; + } + return { + outcome: 'applied', + result: jsonValue( + recoveryRequiredResult( + request, + currentFingerprint, + 'Target settings match neither the previous nor proposed fingerprint' + ) + ), + }; + }, + }, + async () => jsonValue(await execute()) + ); + return withReplay( + run.result as UpdateMemberSettingsResult, + run.outcome === ApplicationCommandRunOutcome.Replayed + ); + }, + }; +} + +/** Keeps legacy main-process wiring out of the application shell. */ +export function createNodeTeamMemberSettingsFeature( + dependencies: NodeTeamMemberSettingsFeatureDependencies +): TeamMemberSettingsFeatureApi { + const isTeamAlive = (teamName: string) => dependencies.runtime.isTeamAlive(teamName); + return createTeamMemberSettingsFeature({ + commandRunner: dependencies.commandRunner, + mutationSource: dependencies.memberLifecycle, + lifecycleSource: { + attachLiveRosterMember: (teamName, memberName, options) => + dependencies.memberLifecycle.attachLiveRosterMember(teamName, memberName, options), + isTeamAlive, + }, + repositoryDependencies: createNodeLegacyMemberSettingsRepositoryDependencies({ + isTeamAlive, + invalidateWorkerCache: (teamName) => { + const cache = dependencies.getWorkerCache(); + cache.invalidateTeamConfig(teamName); + cache.invalidateMemberRuntimeAdvisory(teamName); + }, + }), + }); +} diff --git a/src/features/team-provisioning/main/index.ts b/src/features/team-provisioning/main/index.ts index 798fa00d02..fcc5710e54 100644 --- a/src/features/team-provisioning/main/index.ts +++ b/src/features/team-provisioning/main/index.ts @@ -1,3 +1,18 @@ +export { + registerTeamMemberSettingsIpc, + removeTeamMemberSettingsIpc, +} from './adapters/input/registerTeamMemberSettingsIpc'; +export { + createNodeLegacyMemberSettingsRepositoryDependencies, + type NodeLegacyMemberSettingsRepositoryOptions, +} from './adapters/output/LegacyMemberSettingsRepositoryAdapter'; +export { + createNodeTeamMemberSettingsFeature, + createTeamMemberSettingsFeature, + type NodeTeamMemberSettingsFeatureDependencies, + type TeamMemberSettingsFeatureApi, + type TeamMemberSettingsFeatureDependencies, +} from './composition/createTeamMemberSettingsFeature'; export { createTeamProvisioningStatusFeature, type TeamProvisioningStatusFeatureDeps, diff --git a/src/features/team-provisioning/preload/createTeamMemberSettingsBridge.ts b/src/features/team-provisioning/preload/createTeamMemberSettingsBridge.ts new file mode 100644 index 0000000000..d83b204725 --- /dev/null +++ b/src/features/team-provisioning/preload/createTeamMemberSettingsBridge.ts @@ -0,0 +1,14 @@ +import { + TEAM_UPDATE_MEMBER_SETTINGS, + type TeamMemberSettingsApi, + type UpdateMemberSettingsResult, +} from '@features/team-provisioning/contracts'; + +export type InvokeIpcWithResult = (channel: string, ...args: unknown[]) => Promise; + +export function createTeamMemberSettingsBridge(invoke: InvokeIpcWithResult): TeamMemberSettingsApi { + return { + updateMemberSettings: (request) => + invoke(TEAM_UPDATE_MEMBER_SETTINGS, request), + }; +} diff --git a/src/features/team-provisioning/preload/index.ts b/src/features/team-provisioning/preload/index.ts new file mode 100644 index 0000000000..4413160f41 --- /dev/null +++ b/src/features/team-provisioning/preload/index.ts @@ -0,0 +1,4 @@ +export { + createTeamMemberSettingsBridge, + type InvokeIpcWithResult, +} from './createTeamMemberSettingsBridge'; diff --git a/src/features/team-provisioning/renderer/hooks/useUpdateMemberSettings.test.tsx b/src/features/team-provisioning/renderer/hooks/useUpdateMemberSettings.test.tsx new file mode 100644 index 0000000000..124f3e53e5 --- /dev/null +++ b/src/features/team-provisioning/renderer/hooks/useUpdateMemberSettings.test.tsx @@ -0,0 +1,80 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const updateMemberSettings = vi.hoisted(() => vi.fn()); + +vi.mock('@renderer/api', () => ({ api: { teams: { updateMemberSettings } } })); + +import { useUpdateMemberSettings } from './useUpdateMemberSettings'; + +import type { EditableMemberSettings } from '../../contracts/memberSettings'; + +const baseSettings: EditableMemberSettings = { + role: 'developer', + workflow: null, + isolation: null, + providerId: null, + providerBackendId: null, + model: null, + effort: null, + fastMode: null, + mcpPolicy: null, +}; + +const Harness = ({ settings }: { settings: EditableMemberSettings }): React.JSX.Element => { + const { save } = useUpdateMemberSettings(); + return ( + + ); +}; + +let host: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000001') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000002'); + updateMemberSettings.mockRejectedValue(new Error('retryable')); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); +}); + +describe('useUpdateMemberSettings', () => { + it('reuses identity only while retrying the same payload', async () => { + act(() => root.render()); + await act(async () => host.querySelector('button')?.click()); + await act(async () => host.querySelector('button')?.click()); + + act(() => root.render()); + await act(async () => host.querySelector('button')?.click()); + + expect(updateMemberSettings.mock.calls.map(([request]) => request.commandId)).toEqual([ + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000002', + ]); + }); +}); diff --git a/src/features/team-provisioning/renderer/hooks/useUpdateMemberSettings.ts b/src/features/team-provisioning/renderer/hooks/useUpdateMemberSettings.ts new file mode 100644 index 0000000000..b90a616c59 --- /dev/null +++ b/src/features/team-provisioning/renderer/hooks/useUpdateMemberSettings.ts @@ -0,0 +1,55 @@ +import { useCallback, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; + +import type { + UpdateMemberSettingsRequest, + UpdateMemberSettingsResult, +} from '../../contracts/memberSettings'; + +interface PendingIdentity { + commandId: string; + idempotencyKey: string; + payloadKey: string; +} + +function createIdentity(payloadKey: string): PendingIdentity { + const commandId = crypto.randomUUID(); + return { commandId, idempotencyKey: commandId, payloadKey }; +} + +export function useUpdateMemberSettings(): { + saving: boolean; + save: ( + request: Omit + ) => Promise; + resetIdentity: () => void; +} { + const [saving, setSaving] = useState(false); + const identityRef = useRef(null); + + const save = useCallback( + async ( + request: Omit + ): Promise => { + const payloadKey = JSON.stringify(request); + if (identityRef.current?.payloadKey !== payloadKey) { + identityRef.current = createIdentity(payloadKey); + } + setSaving(true); + try { + const { commandId, idempotencyKey } = identityRef.current; + return await api.teams.updateMemberSettings({ ...request, commandId, idempotencyKey }); + } finally { + setSaving(false); + } + }, + [] + ); + + const resetIdentity = useCallback(() => { + identityRef.current = null; + }, []); + + return { saving, save, resetIdentity }; +} diff --git a/src/features/team-provisioning/renderer/index.ts b/src/features/team-provisioning/renderer/index.ts new file mode 100644 index 0000000000..e51ca0270a --- /dev/null +++ b/src/features/team-provisioning/renderer/index.ts @@ -0,0 +1,3 @@ +export type { EditTeamMemberDialogProps } from './ui/EditTeamMemberDialog'; +export { EditTeamMemberDialog } from './ui/EditTeamMemberDialog'; +export { TeamMemberSettingsDialogBridge } from './ui/TeamMemberSettingsDialogBridge'; diff --git a/src/features/team-provisioning/renderer/ui/EditTeamMemberDialog.test.tsx b/src/features/team-provisioning/renderer/ui/EditTeamMemberDialog.test.tsx new file mode 100644 index 0000000000..5cf62e9c94 --- /dev/null +++ b/src/features/team-provisioning/renderer/ui/EditTeamMemberDialog.test.tsx @@ -0,0 +1,413 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const updateMemberSettings = vi.hoisted(() => vi.fn()); + +vi.mock('@renderer/api', () => ({ api: { teams: { updateMemberSettings } } })); +vi.mock('@features/localization/renderer', () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@renderer/components/ui/button', () => ({ + Button: (props: React.ButtonHTMLAttributes) => + React.createElement('button', { ...props, type: 'button' }), +})); +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ + children, + onOpenChange, + }: { + children: React.ReactNode; + onOpenChange: (open: boolean) => void; + }) => + React.createElement( + 'div', + null, + React.createElement( + 'button', + { type: 'button', 'data-testid': 'dismiss', onClick: () => onOpenChange(false) }, + 'dismiss' + ), + children + ), + DialogContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), + DialogDescription: ({ children }: { children: React.ReactNode }) => + React.createElement('p', null, children), + DialogFooter: ({ children }: { children: React.ReactNode }) => + React.createElement('footer', null, children), + DialogHeader: ({ children }: { children: React.ReactNode }) => + React.createElement('header', null, children), + DialogTitle: ({ children }: { children: React.ReactNode }) => + React.createElement('h2', null, children), +})); +vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({ + createMemberDraftsFromInputs: (members: Array>) => + members.map((member) => ({ + id: member.name, + name: member.name, + originalName: member.name, + roleSelection: member.role ?? '', + customRole: '', + workflow: member.workflow, + providerId: member.providerId, + providerBackendId: member.providerBackendId, + model: member.model ?? '', + effort: member.effort, + fastMode: member.fastMode, + mcpPolicy: member.mcpPolicy, + isolation: member.isolation, + })), + MembersEditorSection: ({ + members, + onChange, + singleMemberMode, + inheritedProviderId, + }: { + members: Array>; + onChange: (members: Array>) => void; + singleMemberMode?: boolean; + inheritedProviderId?: string; + }) => + React.createElement( + React.Fragment, + null, + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'editor', + 'data-single': String(singleMemberMode), + 'data-inherited-provider': inheritedProviderId, + onClick: () => onChange([{ ...members[0], roleSelection: 'reviewer' }]), + }, + 'editor' + ), + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'reserved-editor', + onClick: () => + onChange([{ ...members[0], roleSelection: '__custom__', customRole: 'Team Lead' }]), + }, + 'reserved editor' + ) + ), +})); + +import { EditTeamMemberDialog } from './EditTeamMemberDialog'; + +import type { ResolvedTeamMember } from '@shared/types'; + +const member: ResolvedTeamMember = { + name: 'alice', + agentId: 'agent-1', + agentType: 'developer', + status: 'idle', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + role: 'developer', + providerId: 'anthropic', +}; + +let host: HTMLDivElement; +let root: ReturnType; +let onClose: ReturnType; +let onRefresh: ReturnType; +let onRelaunchRequired: ReturnType; + +function render(overrides: Partial> = {}): void { + root.render( + + ); +} + +function saveButton(): HTMLButtonElement { + return Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('editTeam.actions.save') + )!; +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-4000-8000-000000000001'); + updateMemberSettings.mockReset(); + onClose = vi.fn(); + onRefresh = vi.fn(async () => {}); + onRelaunchRequired = vi.fn(); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); +}); + +describe('EditTeamMemberDialog', () => { + it('saves one draft with stable command identity and closes a no-op after refresh', async () => { + updateMemberSettings.mockResolvedValue({ + outcome: 'completed', + effect: 'no_changes', + memberName: 'alice', + previousFingerprint: 'same', + currentFingerprint: 'same', + }); + act(() => render()); + expect(host.querySelector('[data-testid="editor"]')?.getAttribute('data-single')).toBe('true'); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + await act(async () => saveButton().click()); + + expect(updateMemberSettings).toHaveBeenCalledWith( + expect.objectContaining({ + commandId: '00000000-0000-4000-8000-000000000001', + idempotencyKey: '00000000-0000-4000-8000-000000000001', + teamName: 'alpha', + memberName: 'alice', + }) + ); + expect(onRefresh).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('refreshes and stays open on conflict', async () => { + updateMemberSettings.mockResolvedValue({ + outcome: 'target_conflict', + memberName: 'alice', + expectedFingerprint: 'old', + actualFingerprint: 'new', + }); + act(() => render()); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + await act(async () => saveButton().click()); + expect(onRefresh).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); + expect(host.textContent).toContain('editTeam.errors.settingsChanged'); + }); + + it('stays open and allows a fresh command after a busy result', async () => { + updateMemberSettings.mockResolvedValue({ + outcome: 'busy', + teamName: 'alpha', + memberName: 'alice', + replayed: false, + }); + act(() => render()); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + await act(async () => saveButton().click()); + expect(onClose).not.toHaveBeenCalled(); + expect(host.textContent).toContain('editTeam.errors.saveFailed'); + }); + + it('refreshes and stays open when recovery is required', async () => { + updateMemberSettings.mockResolvedValue({ + outcome: 'completed', + effect: 'recovery_required', + memberName: 'alice', + previousFingerprint: 'old', + currentFingerprint: 'old', + }); + act(() => render()); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + await act(async () => saveButton().click()); + expect(onRefresh).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); + expect(host.textContent).toContain('editTeam.errors.saveFailed'); + }); + + it('refreshes current truth after a failed mutation', async () => { + updateMemberSettings.mockRejectedValue(new Error('failed')); + act(() => render()); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + await act(async () => saveButton().click()); + expect(onRefresh).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('stays open and does not repeat refresh when refresh fails after a successful save', async () => { + updateMemberSettings.mockResolvedValue({ + outcome: 'completed', + effect: 'persisted_only', + memberName: 'alice', + previousFingerprint: 'old', + currentFingerprint: 'new', + }); + onRefresh.mockRejectedValue(new Error('offline')); + act(() => render()); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + await act(async () => saveButton().click()); + + expect(updateMemberSettings).toHaveBeenCalledOnce(); + expect(onRefresh).toHaveBeenCalledOnce(); + expect(onClose).not.toHaveBeenCalled(); + expect(host.textContent).toContain('editTeam.errors.changesSavedRefreshFailed'); + }); + + it('does not claim settings were saved when refresh fails after a no-op result', async () => { + updateMemberSettings.mockResolvedValue({ + outcome: 'completed', + effect: 'no_changes', + memberName: 'alice', + previousFingerprint: 'same', + currentFingerprint: 'same', + }); + onRefresh.mockRejectedValue(new Error('offline')); + act(() => render()); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + await act(async () => saveButton().click()); + + expect(host.textContent).toContain('editTeam.errors.saveFailed'); + expect(host.textContent).not.toContain('editTeam.errors.changesSavedRefreshFailed'); + }); + + it('switches unsafe live edits to the existing relaunch action without mutation', () => { + act(() => render({ isTeamAlive: true, isMixedTeam: true })); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + const relaunch = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('activity.actions.restartTeam') + )!; + act(() => relaunch.click()); + expect(updateMemberSettings).not.toHaveBeenCalled(); + expect(onRelaunchRequired).toHaveBeenCalledOnce(); + }); + + it('blocks close while saving', async () => { + let resolve!: (value: unknown) => void; + updateMemberSettings.mockReturnValue(new Promise((done) => (resolve = done))); + act(() => render()); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + act(() => saveButton().click()); + expect(saveButton().disabled).toBe(true); + act(() => host.querySelector('[data-testid="dismiss"]')?.click()); + expect(onClose).not.toHaveBeenCalled(); + await act(async () => { + resolve({ + outcome: 'completed', + effect: 'persisted_only', + memberName: 'alice', + previousFingerprint: 'old', + currentFingerprint: 'new', + }); + await Promise.resolve(); + }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('disables save for a normalized no-op', () => { + act(() => render()); + expect(saveButton().disabled).toBe(true); + }); + + it('disables save for a reserved role', () => { + act(() => render()); + act(() => host.querySelector('[data-testid="reserved-editor"]')?.click()); + expect(saveButton().disabled).toBe(true); + }); + + it('uses the lead provider for inherited model controls', () => { + act(() => render({ leadProviderId: 'codex' })); + expect( + host.querySelector('[data-testid="editor"]')?.getAttribute('data-inherited-provider') + ).toBe('codex'); + }); + + it('does not persist effective inherited runtime values as explicit overrides', async () => { + updateMemberSettings.mockResolvedValue({ + outcome: 'completed', + effect: 'persisted_only', + memberName: 'alice', + previousFingerprint: 'old', + currentFingerprint: 'new', + replayed: false, + }); + act(() => + render({ + leadProviderId: 'codex', + member: { + ...member, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'effective-model', + selectedFastMode: 'on', + configuredRuntimeSettings: {}, + }, + }) + ); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + await act(async () => saveButton().click()); + + expect(updateMemberSettings).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ + providerId: null, + providerBackendId: null, + model: null, + fastMode: null, + }), + }) + ); + }); + + it('shows the current task warning and the exact OpenCode lane action', () => { + act(() => + render({ + isTeamAlive: true, + member: { + ...member, + providerId: 'opencode', + configuredRuntimeSettings: { providerId: 'opencode' }, + laneId: 'secondary-1', + laneKind: 'secondary', + currentTaskId: 'task-42', + }, + }) + ); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + expect(host.textContent).toContain('detail.actions.task: task-42'); + expect(host.textContent).toContain('liveRuntimeStatus.lane'); + }); + + it('updates the task warning from live props without resetting the open draft', async () => { + updateMemberSettings.mockResolvedValue({ + outcome: 'completed', + effect: 'persisted_only', + memberName: 'alice', + previousFingerprint: 'old', + currentFingerprint: 'new', + replayed: false, + }); + act(() => render({ isTeamAlive: true })); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + act(() => render({ isTeamAlive: true, member: { ...member, currentTaskId: 'task-42' } })); + + expect(host.textContent).toContain('detail.actions.task: task-42'); + await act(async () => saveButton().click()); + expect(updateMemberSettings).toHaveBeenCalledWith( + expect.objectContaining({ settings: expect.objectContaining({ role: 'reviewer' }) }) + ); + }); + + it('keeps a disappeared target visible as stale and disables save', () => { + act(() => render({ targetAvailable: false })); + act(() => host.querySelector('[data-testid="editor"]')?.click()); + expect(host.textContent).toContain('editTeam.errors.settingsChanged'); + expect(saveButton().disabled).toBe(true); + }); +}); diff --git a/src/features/team-provisioning/renderer/ui/EditTeamMemberDialog.tsx b/src/features/team-provisioning/renderer/ui/EditTeamMemberDialog.tsx new file mode 100644 index 0000000000..0a67723418 --- /dev/null +++ b/src/features/team-provisioning/renderer/ui/EditTeamMemberDialog.tsx @@ -0,0 +1,248 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useAppTranslation } from '@features/localization/renderer'; +import { + createMemberDraftsFromInputs, + MembersEditorSection, +} from '@renderer/components/team/members/MembersEditorSection'; +import { Button } from '@renderer/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { isForbiddenTeamRole } from '@renderer/constants/teamRoles'; + +import { useUpdateMemberSettings } from '../hooks/useUpdateMemberSettings'; +import { + deriveMemberSettingsSaveImpact, + draftToEditableSettings, + fingerprintResolvedMember, + hasEditableMemberSettingsChanges, +} from '../utils/memberSettingsPresentation'; + +import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection'; +import type { EffortLevel, ResolvedTeamMember, TeamProviderId } from '@shared/types'; + +export interface EditTeamMemberDialogProps { + open: boolean; + teamName: string; + member: ResolvedTeamMember; + isTeamAlive: boolean; + isTeamProvisioning: boolean; + isMixedTeam: boolean; + leadProviderId?: TeamProviderId; + leadModel?: string; + leadEffort?: EffortLevel; + projectPath?: string | null; + targetAvailable?: boolean; + onClose: () => void; + onRefresh: () => Promise | void; + onRelaunchRequired: () => void; +} + +function createDraft(member: ResolvedTeamMember): MemberDraft { + const configured = member.configuredRuntimeSettings; + return createMemberDraftsFromInputs([ + { + ...member, + providerId: configured?.providerId ?? (configured ? undefined : member.providerId), + providerBackendId: + configured?.providerBackendId ?? (configured ? undefined : member.providerBackendId), + model: configured?.model ?? (configured ? undefined : member.model), + effort: configured?.effort ?? (configured ? undefined : member.effort), + fastMode: configured?.fastMode ?? (configured ? undefined : member.selectedFastMode), + }, + ])[0]; +} + +export const EditTeamMemberDialog = ({ + open, + teamName, + member, + isTeamAlive, + isTeamProvisioning, + isMixedTeam, + leadProviderId, + leadModel, + leadEffort, + projectPath, + targetAvailable = true, + onClose, + onRefresh, + onRelaunchRequired, +}: EditTeamMemberDialogProps): React.JSX.Element => { + const { t } = useAppTranslation('team'); + const [baseline, setBaseline] = useState(member); + const [draft, setDraft] = useState(() => createDraft(member)); + const [error, setError] = useState(null); + const [acceptRefreshedTarget, setAcceptRefreshedTarget] = useState(false); + const { saving, save, resetIdentity } = useUpdateMemberSettings(); + const incomingFingerprint = useMemo(() => fingerprintResolvedMember(member), [member]); + const fingerprint = useMemo(() => fingerprintResolvedMember(baseline), [baseline]); + const settings = useMemo(() => draftToEditableSettings(draft), [draft]); + const impact = deriveMemberSettingsSaveImpact({ + member: baseline, + proposedProviderId: settings.providerId, + isTeamAlive, + leadProviderId, + isMixedTeam, + }); + const hasChanges = hasEditableMemberSettingsChanges(baseline, settings); + const hasInvalidRole = settings.role ? isForbiddenTeamRole(settings.role) : false; + + useEffect(() => { + if (acceptRefreshedTarget && !saving) { + setBaseline(member); + setDraft(createDraft(member)); + setAcceptRefreshedTarget(false); + } + }, [acceptRefreshedTarget, incomingFingerprint, member, saving]); + + const close = (): void => { + if (saving) return; + resetIdentity(); + setError(null); + onClose(); + }; + + const handleSave = async (): Promise => { + if (!targetAvailable) return; + setError(null); + if (impact === 'relaunch') { + resetIdentity(); + onRelaunchRequired(); + return; + } + let result: Awaited>; + try { + result = await save({ + teamName, + memberName: baseline.name, + expectedFingerprint: fingerprint, + settings, + }); + } catch { + try { + await onRefresh(); + } catch { + // Preserve the mutation failure while still attempting current-truth refresh. + } + setError(t('editTeam.errors.saveFailed')); + return; + } + try { + await onRefresh(); + } catch (refreshError) { + const message = refreshError instanceof Error ? refreshError.message : String(refreshError); + const persistenceCompleted = + result.outcome === 'completed' && + (result.effect === 'persisted_only' || + result.effect === 'member_restart_started' || + result.effect === 'opencode_lane_restart_started'); + setError( + persistenceCompleted + ? t('editTeam.errors.changesSavedRefreshFailed', { message }) + : t('editTeam.errors.saveFailed') + ); + return; + } + if (result.outcome === 'busy') { + resetIdentity(); + setError(t('editTeam.errors.saveFailed')); + return; + } + if (result.outcome === 'target_conflict') { + resetIdentity(); + setAcceptRefreshedTarget(true); + setError(t('editTeam.errors.settingsChanged')); + return; + } + if (result.effect === 'team_relaunch_required') { + resetIdentity(); + onRelaunchRequired(); + return; + } + if (result.effect === 'recovery_required') { + setError(t('editTeam.errors.saveFailed')); + return; + } + resetIdentity(); + close(); + }; + + const restartLabel = `${t('editTeam.actions.save')} + ${t('members.detail.restart')}`; + const laneRestartLabel = `${restartLabel} (${t('liveRuntimeStatus.lane', { lane: baseline.laneId ?? 'OpenCode' })})`; + const saveLabel = + impact === 'offline' + ? t('editTeam.actions.save') + : impact === 'opencode_restart' + ? laneRestartLabel + : impact === 'relaunch' + ? t('activity.actions.restartTeam') + : restartLabel; + + return ( + !nextOpen && close()}> + saving && event.preventDefault()} + onPointerDownOutside={(event) => saving && event.preventDefault()} + > + + {`${t('toolApproval.settings')}: ${baseline.name}`} + {baseline.role?.trim() || t('memberDraft.noRole')} + + members[0] && setDraft(members[0])} + singleMemberMode + showWorkflow + showJsonEditor={false} + showWorktreeIsolationControls + lockExistingMemberIdentity + identityLockReason={t('editTeam.notices.liveRenameBlocked')} + draftKeyPrefix={`editMember:${teamName}:${member.name}`} + projectPath={projectPath} + defaultProviderId={leadProviderId} + inheritedProviderId={leadProviderId} + inheritedModel={leadModel} + inheritedEffort={leadEffort} + /> + {impact !== 'offline' ? ( +

+ {impact === 'relaunch' + ? t('editTeam.notices.unsupportedMixedPrimaryMutation', { names: baseline.name }) + : t('editTeam.memberRestartWarning')} +

+ ) : null} + {isTeamAlive && member.currentTaskId ? ( +

+ {`${t('detail.actions.task')}: ${member.currentTaskId}. ${t('editTeam.memberRestartWarning')}`} +

+ ) : null} + {error || !targetAvailable ? ( +

+ {error ?? t('editTeam.errors.settingsChanged')} +

+ ) : null} + + + + +
+
+ ); +}; diff --git a/src/features/team-provisioning/renderer/ui/TeamMemberSettingsDialogBridge.test.tsx b/src/features/team-provisioning/renderer/ui/TeamMemberSettingsDialogBridge.test.tsx new file mode 100644 index 0000000000..7665fb5256 --- /dev/null +++ b/src/features/team-provisioning/renderer/ui/TeamMemberSettingsDialogBridge.test.tsx @@ -0,0 +1,92 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const dialogProps = vi.hoisted(() => vi.fn()); + +vi.mock('./EditTeamMemberDialog', () => ({ + EditTeamMemberDialog: (props: Record) => { + dialogProps(props); + const [initialMember] = React.useState(props.member as ResolvedTeamMember); + return React.createElement('div', { + 'data-testid': 'member-dialog', + 'data-agent-id': initialMember.agentId, + }); + }, +})); + +import { TeamMemberSettingsDialogBridge } from './TeamMemberSettingsDialogBridge'; + +import type { ResolvedTeamMember } from '@shared/types'; + +const member: ResolvedTeamMember = { + name: 'alice', + agentType: 'developer', + status: 'idle', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + role: 'developer', + providerId: 'codex', +}; + +let host: HTMLDivElement; +let root: ReturnType; + +function render(members: readonly ResolvedTeamMember[]): void { + root.render( + + ); +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + dialogProps.mockReset(); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); +}); + +describe('TeamMemberSettingsDialogBridge', () => { + it('keeps the last target visible but stale when it disappears during editing', () => { + act(() => render([member])); + expect(dialogProps).toHaveBeenLastCalledWith( + expect.objectContaining({ member, targetAvailable: true }) + ); + + act(() => render([])); + expect(host.querySelector('[data-testid="member-dialog"]')).not.toBeNull(); + expect(dialogProps).toHaveBeenLastCalledWith( + expect.objectContaining({ member, targetAvailable: false }) + ); + }); + + it('remounts the dialog when a same-name target gets a new runtime identity', () => { + act(() => render([{ ...member, agentId: 'agent-1' }])); + expect(host.querySelector('[data-testid="member-dialog"]')?.getAttribute('data-agent-id')).toBe( + 'agent-1' + ); + + act(() => render([{ ...member, agentId: 'agent-2' }])); + expect(host.querySelector('[data-testid="member-dialog"]')?.getAttribute('data-agent-id')).toBe( + 'agent-2' + ); + }); +}); diff --git a/src/features/team-provisioning/renderer/ui/TeamMemberSettingsDialogBridge.tsx b/src/features/team-provisioning/renderer/ui/TeamMemberSettingsDialogBridge.tsx new file mode 100644 index 0000000000..491a961884 --- /dev/null +++ b/src/features/team-provisioning/renderer/ui/TeamMemberSettingsDialogBridge.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; + +import { isLeadMember } from '@shared/utils/leadDetection'; + +import { EditTeamMemberDialog } from './EditTeamMemberDialog'; + +import type { ResolvedTeamMember } from '@shared/types'; + +interface TeamMemberSettingsDialogBridgeProps { + teamName: string; + memberName: string; + members: readonly ResolvedTeamMember[]; + isTeamAlive: boolean; + isTeamProvisioning: boolean; + projectPath?: string | null; + onClose: () => void; + onRefresh: () => Promise | void; + onRelaunchRequired: () => void; +} + +export const TeamMemberSettingsDialogBridge = ({ + teamName, + memberName, + members, + isTeamAlive, + isTeamProvisioning, + projectPath, + onClose, + onRefresh, + onRelaunchRequired, +}: TeamMemberSettingsDialogBridgeProps): React.JSX.Element | null => { + const currentMember = members.find((candidate) => candidate.name === memberName); + const targetAvailable = Boolean( + currentMember && !currentMember.removedAt && !isLeadMember(currentMember) + ); + const lastMemberRef = useRef( + targetAvailable ? (currentMember ?? null) : null + ); + useEffect(() => { + if (targetAvailable && currentMember) lastMemberRef.current = currentMember; + }, [currentMember, targetAvailable]); + const member = targetAvailable ? currentMember : lastMemberRef.current; + if (!member) return null; + const lead = members.find((candidate) => isLeadMember(candidate)); + const providerIds = new Set( + members + .filter((candidate) => !candidate.removedAt) + .map((candidate) => candidate.providerId ?? lead?.providerId) + .filter(Boolean) + ); + + return ( + 1} + leadProviderId={lead?.providerId} + leadModel={lead?.model} + leadEffort={lead?.effort} + projectPath={projectPath} + targetAvailable={targetAvailable} + onClose={onClose} + onRefresh={onRefresh} + onRelaunchRequired={onRelaunchRequired} + /> + ); +}; diff --git a/src/features/team-provisioning/renderer/utils/memberSettingsPresentation.ts b/src/features/team-provisioning/renderer/utils/memberSettingsPresentation.ts new file mode 100644 index 0000000000..5e36d023a1 --- /dev/null +++ b/src/features/team-provisioning/renderer/utils/memberSettingsPresentation.ts @@ -0,0 +1,101 @@ +import { + createMemberSettingsFingerprint, + normalizeEditableMemberSettings, +} from '../../core/domain/memberSettingsPolicy'; + +import type { EditableMemberSettings } from '../../contracts/memberSettings'; +import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection'; +import type { ResolvedTeamMember, TeamProviderId } from '@shared/types'; + +export type MemberSettingsSaveImpact = 'offline' | 'restart' | 'opencode_restart' | 'relaunch'; + +export function memberToEditableSettings(member: ResolvedTeamMember): EditableMemberSettings { + const configured = member.configuredRuntimeSettings; + return { + role: member.role?.trim() || null, + workflow: member.workflow?.trim() || null, + isolation: member.isolation ?? null, + providerId: configured ? (configured.providerId ?? null) : (member.providerId ?? null), + providerBackendId: configured + ? (configured.providerBackendId ?? null) + : (member.providerBackendId ?? null), + model: (configured ? configured.model : member.model)?.trim() || null, + effort: configured ? (configured.effort ?? null) : (member.effort ?? null), + fastMode: configured ? (configured.fastMode ?? null) : (member.selectedFastMode ?? null), + mcpPolicy: member.mcpPolicy + ? { + mode: member.mcpPolicy.mode, + ...(member.mcpPolicy.scopes ? { scopes: { ...member.mcpPolicy.scopes } } : {}), + ...(member.mcpPolicy.serverNames + ? { serverNames: [...member.mcpPolicy.serverNames] } + : {}), + } + : null, + }; +} + +export function draftToEditableSettings(draft: MemberDraft): EditableMemberSettings { + const role = + draft.roleSelection === '__custom__' ? draft.customRole.trim() : draft.roleSelection.trim(); + return { + role: role || null, + workflow: draft.workflow?.trim() || null, + isolation: draft.isolation ?? null, + providerId: draft.providerId ?? null, + providerBackendId: draft.providerBackendId ?? null, + model: draft.model?.trim() || null, + effort: draft.effort ?? null, + fastMode: draft.fastMode ?? null, + mcpPolicy: draft.mcpPolicy + ? { + mode: draft.mcpPolicy.mode, + ...(draft.mcpPolicy.scopes ? { scopes: { ...draft.mcpPolicy.scopes } } : {}), + ...(draft.mcpPolicy.serverNames ? { serverNames: [...draft.mcpPolicy.serverNames] } : {}), + } + : null, + }; +} + +export function fingerprintResolvedMember(member: ResolvedTeamMember): string { + return createMemberSettingsFingerprint({ + name: member.name, + agentType: member.agentType ?? null, + agentId: member.agentId ?? null, + joinedAt: member.joinedAt ?? null, + settings: memberToEditableSettings(member), + teamIsAlive: false, + leadProviderId: null, + teamIsMixed: false, + runtimeLane: member.laneKind === 'secondary' ? 'opencode_secondary' : 'primary', + }); +} + +export function hasEditableMemberSettingsChanges( + member: ResolvedTeamMember, + settings: EditableMemberSettings +): boolean { + return ( + JSON.stringify(normalizeEditableMemberSettings(memberToEditableSettings(member))) !== + JSON.stringify(normalizeEditableMemberSettings(settings)) + ); +} + +export function deriveMemberSettingsSaveImpact(input: { + member: ResolvedTeamMember; + proposedProviderId: TeamProviderId | null; + isTeamAlive: boolean; + leadProviderId?: TeamProviderId; + isMixedTeam: boolean; +}): MemberSettingsSaveImpact { + if (!input.isTeamAlive) return 'offline'; + if ( + input.leadProviderId === 'opencode' || + (input.member.providerId === 'opencode') !== (input.proposedProviderId === 'opencode') || + (input.isMixedTeam && input.proposedProviderId !== 'opencode') + ) { + return 'relaunch'; + } + return input.member.laneKind === 'secondary' && input.proposedProviderId === 'opencode' + ? 'opencode_restart' + : 'restart'; +} diff --git a/src/main/index.ts b/src/main/index.ts index cd79175731..690c789976 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -95,6 +95,7 @@ import { removeTeamImportIpc, type TeamImportFeatureFacade, } from '@features/team-import/main'; +import * as teamMemberSettings from '@features/team-provisioning/main'; import { createTeamRuntimeRecoveryFeature, type TeamRuntimeRecoveryFeatureFacade, @@ -1983,16 +1984,12 @@ async function initializeServices(): Promise { ready: false, error: null, }); - // Initialize SSH connection manager sshConnectionManager = new SshConnectionManager(); - // Create ServiceContextRegistry contextRegistry = new ServiceContextRegistry(); - const localProjectsDir = getProjectsBasePath(); const localTodosDir = getTodosBasePath(); - // Create local context const localContext = new ServiceContext({ id: 'local', @@ -2001,28 +1998,21 @@ async function initializeServices(): Promise { projectsDir: localProjectsDir, todosDir: localTodosDir, }); - // Register context and start cache cleanup only. // FileWatcher is deferred to did-finish-load to avoid blocking window creation // with fs.watch() setup (especially slow on Windows NTFS with recursive watchers). contextRegistry.registerContext(localContext); localContext.startCacheOnly(); - logger.info(`Projects directory: ${localContext.projectScanner.getProjectsDir()}`); - // Initialize notification manager (singleton, not context-scoped) notificationManager = NotificationManager.getInstance(); - // Set notification manager on local context's file watcher localContext.fileWatcher.setNotificationManager(notificationManager); - launchIoGovernor = new LaunchIoGovernor({ logger: createLogger('Service:LaunchIoGovernor'), }); - // Wire file watcher events for local context wireFileWatcherEvents(localContext); - // Initialize updater and CLI installer services updaterService = new UpdaterService(); updaterService.setBeforeQuitAndInstall(async () => { @@ -2093,11 +2083,13 @@ async function initializeServices(): Promise { } teamDataService = new TeamDataService(); const applicationCommandLedgerBackend = internalStorageFeature.applicationCommandLedgerBackend; + let applicationCommandRunner = null; if (applicationCommandLedgerBackend) { const applicationCommandHasher = new NodeApplicationCommandHasher(); const applicationCommandLedgerFeature = createApplicationCommandLedgerFeature({ storageGateway: applicationCommandLedgerBackend.gateway, }); + applicationCommandRunner = applicationCommandLedgerFeature.runner; teamDataService.setTaskBoardCommandFacade( new TaskBoardCommandFacade(applicationCommandLedgerFeature.runner, { isDurableStorageAvailable: () => @@ -2116,6 +2108,12 @@ async function initializeServices(): Promise { const teamMessagingApi = teamIpcHandlerApis.messaging; const teamProvisioningRunApi = teamIpcHandlerApis.provisioningRun; const teamRuntimeApi = teamIpcHandlerApis.runtime; + const teamMemberSettingsFeature = teamMemberSettings.createNodeTeamMemberSettingsFeature({ + commandRunner: applicationCommandRunner, + memberLifecycle: teamIpcHandlerApis.memberLifecycle, + runtime: teamRuntimeApi, + getWorkerCache: getTeamDataWorkerClient, + }); const workspaceTrust = workspaceTrustFeature.createWorkspaceTrustFeatures({ getClaudeConfigDir: getClaudeBasePath, getAutoDetectedClaudeConfigDir: getAutoDetectedClaudeBasePath, @@ -2969,6 +2967,7 @@ async function initializeServices(): Promise { registerCodexAccountIpc(ipcMain, codexAccountFeature); registerRecentProjectsIpc(ipcMain, recentProjectsFeature); registerTeamImportIpc(ipcMain, teamImportFeature); + teamMemberSettings.registerTeamMemberSettingsIpc(ipcMain, teamMemberSettingsFeature); registerOrganizationsIpc(ipcMain, organizationsFeature); registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature); registerTerminalWorkspaceIpc(ipcMain, terminalWorkspaceFeature); @@ -3190,6 +3189,7 @@ async function shutdownServices(): Promise { removeRecentProjectsIpc(ipcMain); workspaceTrustFeature.removeWorkspaceTrustIpc(ipcMain); removeTeamImportIpc(ipcMain); + teamMemberSettings.removeTeamMemberSettingsIpc(ipcMain); removeOrganizationsIpc(ipcMain); removeRuntimeProviderManagementIpc(ipcMain); removeTerminalWorkspaceIpc(ipcMain); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 1ca01d771b..ecf3732c02 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1696,7 +1696,7 @@ function isLeadRosterMutationMember(member: RuntimeRosterMutationMember | undefi if (normalizedName === 'lead') { return true; } - return member.role?.toLowerCase().includes('lead') === true; + return member.role?.trim().toLowerCase().replace(/\s+/g, ' ') === 'team lead'; } function isOpenCodeLedRoster(members: RuntimeRosterMutationMember[]): boolean { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index f2115c057f..e422029c52 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -62,6 +62,7 @@ import { TeamKanbanManager } from './TeamKanbanManager'; import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator'; import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { isMaterializableInboxMemberName, TeamMemberResolver } from './TeamMemberResolver'; +import { planTeamMemberRestore } from './TeamMemberRestorePlan'; import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMessageFeedService } from './TeamMessageFeedService'; @@ -2200,35 +2201,29 @@ export class TeamDataService { } async restoreMember(teamName: string, memberName: string): Promise { - const normalizedName = memberName.trim().toLowerCase(); - const members = await this.membersMetaStore.getMembers(teamName); - const memberIndex = members.findIndex( - (candidate) => candidate.name.trim().toLowerCase() === normalizedName - ); - const member = memberIndex >= 0 ? members[memberIndex] : undefined; + const [members, config] = await Promise.all([ + this.membersMetaStore.getMembers(teamName), + this.configReader.getConfig(teamName), + ]); + const plan = planTeamMemberRestore({ memberName, members, config }); + const nextMembers = applyDistinctRosterColors(plan.nextMembers); - if (!member) { - throw new Error(`Member "${memberName}" not found`); - } - if (member.removedAt == null) { - throw new Error(`Member "${memberName}" is not removed`); - } - if (isLeadMember(member)) { - throw new Error('Cannot restore team lead'); - } + await this.assertRosterMutationAllowed(teamName, toProvisioningMemberShape(nextMembers)); - const restoredMember: TeamMember = { - ...member, - agentId: undefined, - removedAt: undefined, - }; - const nextMembers = applyDistinctRosterColors( - members.map((candidate, index) => (index === memberIndex ? restoredMember : candidate)) - ); + if (plan.nextConfig) { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + // Clear config first. If the metadata write fails, its remaining tombstone + // keeps the member removed and makes a retry converge safely. + await atomicWriteAsync(configPath, JSON.stringify(plan.nextConfig, null, 2)); + await TeamConfigReader.primeConfig(teamName, plan.nextConfig); + } - await this.assertRosterMutationAllowed(teamName, toProvisioningMemberShape(nextMembers)); await this.membersMetaStore.writeMembers(teamName, nextMembers); - return nextMembers[memberIndex] ?? restoredMember; + return ( + nextMembers.find( + (candidate) => candidate.name.trim().toLowerCase() === plan.normalizedMemberName + ) ?? plan.restoredMember + ); } async createTask(teamName: string, request: CreateTaskRequest): Promise { diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 1c4226d8ca..990a8ac055 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -157,18 +157,21 @@ export class TeamMemberResolver { string, { agentId?: string; + joinedAt?: number; agentType?: string; role?: string; workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; providerBackendId?: TeamProviderBackendId; + configuredProviderBackendId?: TeamProviderBackendId; model?: string; effort?: TeamMember['effort']; fastMode?: TeamMember['fastMode']; mcpPolicy?: TeamMember['mcpPolicy']; color?: string; cwd?: string; + removedAt?: number; } >(); if (Array.isArray(config.members)) { @@ -178,14 +181,16 @@ export class TeamMemberResolver { const providerId = normalizeOptionalTeamProviderId(configMember.providerId) ?? normalizeOptionalTeamProviderId(configMember.provider); - configMemberMap.set(m.name.trim(), { + configMemberMap.set(m.name.trim().toLowerCase(), { agentId: configMember.agentId, + joinedAt: configMember.joinedAt, agentType: configMember.agentType, role: configMember.role, workflow: configMember.workflow, isolation: configMember.isolation === 'worktree' ? ('worktree' as const) : undefined, providerId, providerBackendId: migrateProviderBackendId(providerId, configMember.providerBackendId), + configuredProviderBackendId: configMember.providerBackendId, model: configMember.model, effort: configMember.effort, fastMode: @@ -197,6 +202,7 @@ export class TeamMemberResolver { mcpPolicy: normalizeTeamMemberMcpPolicy(configMember.mcpPolicy), color: configMember.color, cwd: configMember.cwd, + removedAt: configMember.removedAt, }); } } @@ -206,12 +212,14 @@ export class TeamMemberResolver { string, { agentId?: string; + joinedAt?: number; agentType?: string; role?: string; workflow?: string; isolation?: 'worktree'; providerId?: TeamProviderId; providerBackendId?: TeamProviderBackendId; + configuredProviderBackendId?: TeamProviderBackendId; model?: string; effort?: TeamMember['effort']; fastMode?: TeamMember['fastMode']; @@ -224,8 +232,9 @@ export class TeamMemberResolver { if (Array.isArray(metaMembers)) { for (const member of metaMembers) { if (typeof member?.name === 'string' && member.name.trim() !== '') { - metaMemberMap.set(member.name.trim(), { + metaMemberMap.set(member.name.trim().toLowerCase(), { agentId: member.agentId, + joinedAt: member.joinedAt, agentType: member.agentType, role: member.role, workflow: member.workflow, @@ -235,6 +244,7 @@ export class TeamMemberResolver { member.providerId, member.providerBackendId ), + configuredProviderBackendId: member.providerBackendId, model: member.model, effort: member.effort, fastMode: @@ -276,9 +286,11 @@ export class TeamMemberResolver { // Defense: hide CLI auto-suffixed duplicates (alice-2) only when the base // name still exists as an active member. Removed base members must not hide // active suffixed teammates after live mutation / rollback flows. - const activeNamesForAutoSuffix = Array.from(names).filter((name) => { - return !metaMemberMap.get(name)?.removedAt; - }); + const activeNamesForAutoSuffix = Array.from(names).filter( + (name) => + !configMemberMap.get(name.toLowerCase())?.removedAt && + !metaMemberMap.get(name.toLowerCase())?.removedAt + ); const keepName = createCliAutoSuffixNameGuard(activeNamesForAutoSuffix); // Defense: hide CLI provisioner artifacts (alice-provisioner) when base name (alice) exists. const keepProvisioner = createCliProvisionerNameGuard(names); @@ -292,8 +304,8 @@ export class TeamMemberResolver { for (const name of names) { const ownedTasks = tasks.filter((task) => task.owner === name); const currentTask = selectCurrentActiveTeamTask(ownedTasks); - const configMember = configMemberMap.get(name); - const metaMember = metaMemberMap.get(name); + const configMember = configMemberMap.get(name.toLowerCase()); + const metaMember = metaMemberMap.get(name.toLowerCase()); const launchMember = launchMemberMap.get(name); const effectiveProviderId = launchMember?.providerId ?? @@ -320,6 +332,7 @@ export class TeamMemberResolver { members.push({ name, agentId, + joinedAt: configMember?.joinedAt ?? metaMember?.joinedAt, currentTaskId: currentTask?.id ?? null, taskCount: ownedTasks.length, color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name), @@ -339,6 +352,14 @@ export class TeamMemberResolver { (effectiveProviderId === options?.leadProviderId ? (options?.leadFastMode ?? undefined) : undefined), + configuredRuntimeSettings: { + providerId: configMember?.providerId ?? metaMember?.providerId, + providerBackendId: + configMember?.configuredProviderBackendId ?? metaMember?.configuredProviderBackendId, + model: configMember?.model ?? metaMember?.model, + effort: configMember?.effort ?? metaMember?.effort, + fastMode: configMember?.fastMode ?? metaMember?.fastMode, + }, resolvedFastMode: typeof launchMember?.resolvedFastMode === 'boolean' ? launchMember.resolvedFastMode @@ -349,7 +370,7 @@ export class TeamMemberResolver { laneKind: launchMember?.laneKind ?? plannedLane.laneKind, laneOwnerProviderId: launchMember?.laneOwnerProviderId ?? plannedLane.laneOwnerProviderId, cwd: configMember?.cwd ?? metaMember?.cwd, - removedAt: metaMember?.removedAt, + removedAt: configMember?.removedAt ?? metaMember?.removedAt, }); } diff --git a/src/main/services/team/TeamMemberRestorePlan.ts b/src/main/services/team/TeamMemberRestorePlan.ts new file mode 100644 index 0000000000..b6ddc2df6d --- /dev/null +++ b/src/main/services/team/TeamMemberRestorePlan.ts @@ -0,0 +1,72 @@ +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { TeamConfig, TeamMember } from '@shared/types'; + +export interface TeamMemberRestorePlan { + normalizedMemberName: string; + restoredMember: TeamMember; + nextMembers: TeamMember[]; + nextConfig?: TeamConfig; +} + +export function planTeamMemberRestore(input: { + memberName: string; + members: readonly TeamMember[]; + config: TeamConfig | null; +}): TeamMemberRestorePlan { + const normalizedMemberName = input.memberName.trim().toLowerCase(); + const memberIndex = input.members.findIndex( + (candidate) => candidate.name.trim().toLowerCase() === normalizedMemberName + ); + const metaMember = memberIndex >= 0 ? input.members[memberIndex] : undefined; + const configMemberIndex = + input.config?.members?.findIndex( + (candidate) => candidate.name.trim().toLowerCase() === normalizedMemberName + ) ?? -1; + const configMember = + configMemberIndex >= 0 ? input.config?.members?.[configMemberIndex] : undefined; + const member = metaMember ?? configMember; + + if (!member) { + throw new Error(`Member "${input.memberName}" not found`); + } + if (metaMember?.removedAt == null && configMember?.removedAt == null) { + throw new Error(`Member "${input.memberName}" is not removed`); + } + if (isLeadMember(member) || (configMember ? isLeadMember(configMember) : false)) { + throw new Error('Cannot restore team lead'); + } + + const restoredMember: TeamMember = { + ...member, + agentId: undefined, + removedAt: undefined, + }; + const nextMembers = + memberIndex >= 0 + ? input.members.map((candidate, index) => + index === memberIndex ? restoredMember : candidate + ) + : [...input.members, restoredMember]; + + let nextConfig: TeamConfig | undefined; + if (input.config && configMemberIndex >= 0 && configMember?.removedAt != null) { + nextConfig = { + ...input.config, + members: input.config.members?.map((candidate, index) => { + if (index !== configMemberIndex) return candidate; + const restoredConfigMember = { ...candidate }; + delete restoredConfigMember.agentId; + delete restoredConfigMember.removedAt; + return restoredConfigMember; + }), + }; + } + + return { + normalizedMemberName, + restoredMember, + nextMembers, + ...(nextConfig ? { nextConfig } : {}), + }; +} diff --git a/src/main/services/team/contracts/TeamProvisioningApis.ts b/src/main/services/team/contracts/TeamProvisioningApis.ts index ddcb2785f8..e2013a9fc0 100644 --- a/src/main/services/team/contracts/TeamProvisioningApis.ts +++ b/src/main/services/team/contracts/TeamProvisioningApis.ts @@ -145,6 +145,7 @@ export type TeamLiveRosterAttachReason = 'member_added' | 'member_restored' | 'm export interface TeamMemberLifecycleApi { getMemberSpawnStatuses(teamName: string): Promise; runLiveRosterMutation(teamName: string, mutation: () => Promise): Promise; + tryRunLiveRosterMutation?(teamName: string, mutation: () => Promise): Promise; attachLiveRosterMember( teamName: string, memberName: string, @@ -418,6 +419,9 @@ export function bindTeamMemberLifecycleApi(source: TeamMemberLifecycleApi): Team return { getMemberSpawnStatuses: source.getMemberSpawnStatuses.bind(source), runLiveRosterMutation: source.runLiveRosterMutation.bind(source), + ...(source.tryRunLiveRosterMutation + ? { tryRunLiveRosterMutation: source.tryRunLiveRosterMutation.bind(source) } + : {}), attachLiveRosterMember: source.attachLiveRosterMember.bind(source), detachLiveRosterMember: source.detachLiveRosterMember.bind(source), restartMember: source.restartMember.bind(source), diff --git a/src/main/services/team/provisioning/TeamProvisioningServiceMemberLifecycleFacade.ts b/src/main/services/team/provisioning/TeamProvisioningServiceMemberLifecycleFacade.ts index 889f89699b..9c0f552f8d 100644 --- a/src/main/services/team/provisioning/TeamProvisioningServiceMemberLifecycleFacade.ts +++ b/src/main/services/team/provisioning/TeamProvisioningServiceMemberLifecycleFacade.ts @@ -149,7 +149,20 @@ function preserveProvisioningRemovalTombstones(store: TeamMembersMetaStore): Tea /** Owns lifecycle host construction and launch-preparation adaptation. */ export abstract class TeamProvisioningServiceMemberLifecycleFacade extends TeamProvisioningServiceRuntimeStateFacade { async runLiveRosterMutation(teamName: string, mutation: () => Promise): Promise { - await this.executeLiveRosterMutation(teamName, mutation); + await this.executeLiveRosterMutation(teamName.trim().toLowerCase(), mutation); + } + + async tryRunLiveRosterMutation( + teamName: string, + mutation: () => Promise + ): Promise { + const teamKey = teamName.trim().toLowerCase(); + if (this.teamOpLocks.has(teamKey)) return false; + + // executeLiveRosterMutation registers the team lock synchronously before its + // first await, so the check and acquisition remain atomic within this turn. + await this.executeLiveRosterMutation(teamKey, mutation); + return true; } private readonly staleAnthropicApiKeyHelperCleanupRetryOwner = diff --git a/src/main/services/team/provisioning/__tests__/TeamProvisioningRosterMutationLock.test.ts b/src/main/services/team/provisioning/__tests__/TeamProvisioningRosterMutationLock.test.ts index d1529b29f3..1923a35550 100644 --- a/src/main/services/team/provisioning/__tests__/TeamProvisioningRosterMutationLock.test.ts +++ b/src/main/services/team/provisioning/__tests__/TeamProvisioningRosterMutationLock.test.ts @@ -3,6 +3,26 @@ import { describe, expect, it, vi } from 'vitest'; import { TeamProvisioningService } from '../../TeamProvisioningService'; describe('team provisioning roster mutation lock', () => { + it('atomically admits only one same-turn live roster mutation for a normalized team key', async () => { + const service = new TeamProvisioningService(); + let release!: () => void; + const firstMutation = vi.fn(async () => { + await new Promise((resolve) => { + release = resolve; + }); + }); + const secondMutation = vi.fn(async () => undefined); + + const first = service.tryRunLiveRosterMutation(' Busy-Team ', firstMutation); + const second = service.tryRunLiveRosterMutation('busy-team', secondMutation); + + await expect(second).resolves.toBe(false); + expect(firstMutation).toHaveBeenCalledOnce(); + expect(secondMutation).not.toHaveBeenCalled(); + release(); + await expect(first).resolves.toBe(true); + }); + it('does not self-deadlock when the locked transaction delegates to member lifecycle', async () => { const service = new TeamProvisioningService(); const lifecycleController = ( diff --git a/src/main/services/team/provisioning/__tests__/TeamProvisioningServiceFacadeGuard.test.ts b/src/main/services/team/provisioning/__tests__/TeamProvisioningServiceFacadeGuard.test.ts index b0129a2191..d979385ff4 100644 --- a/src/main/services/team/provisioning/__tests__/TeamProvisioningServiceFacadeGuard.test.ts +++ b/src/main/services/team/provisioning/__tests__/TeamProvisioningServiceFacadeGuard.test.ts @@ -88,6 +88,7 @@ const DOCUMENTED_EFFECTIVE_PUBLIC_SERVICE_INSTANCE_MEMBERS = [ 'restartMember', 'retryFailedOpenCodeSecondaryLanes', 'runLiveRosterMutation', + 'tryRunLiveRosterMutation', 'scanOpenCodePromptDeliveryWatchdog', 'scheduleOpenCodeMemberInboxDeliveryWake', 'sendMessageToTeam', diff --git a/src/preload/index.ts b/src/preload/index.ts index 6884fe901c..6fbea10d20 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,6 +7,7 @@ import { createOrganizationsBridge } from '@features/organizations/preload'; import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload'; import { createTeamImportBridge } from '@features/team-import/preload'; +import { createTeamMemberSettingsBridge } from '@features/team-provisioning/preload'; import { createTerminalWorkspaceBridge } from '@features/terminal-workspace/preload'; import { createTmuxInstallerBridge } from '@features/tmux-installer/preload'; import { createTokenUsageBridge } from '@features/token-usage/preload'; @@ -970,7 +971,6 @@ const electronAPI: ElectronAPI = { }; }, }, - // HTTP Server API httpServer: { start: async (): Promise => { @@ -983,8 +983,8 @@ const electronAPI: ElectronAPI = { return invokeIpcWithResult(HTTP_SERVER_GET_STATUS); }, }, - teams: { + ...createTeamMemberSettingsBridge(invokeIpcWithResult), list: async () => { return invokeIpcWithResult(TEAM_LIST); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 055eb6cf3e..99d30040a2 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1195,9 +1195,9 @@ export class HttpAPIClient implements ElectronAPI { updateMemberRole: async (): Promise => { throw new Error('Team member management is not available in browser mode'); }, - getProjectBranch: async (_projectPath: string): Promise => { - return null; - }, + updateMemberSettings: async () => + Promise.reject(new Error('Team member settings updates require the desktop app')), + getProjectBranch: async (_projectPath: string): Promise => null, setProjectBranchTracking: async (): Promise => { // Not available in browser mode — no-op. }, diff --git a/src/renderer/components/team/RoleSelect.tsx b/src/renderer/components/team/RoleSelect.tsx index 102a03bdf8..e7f1c7d75b 100644 --- a/src/renderer/components/team/RoleSelect.tsx +++ b/src/renderer/components/team/RoleSelect.tsx @@ -3,7 +3,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; -import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; +import { + CUSTOM_ROLE, + isForbiddenTeamRole, + NO_ROLE, + PRESET_ROLES, +} from '@renderer/constants/teamRoles'; import { Blocks, BookOpen, Bug, Check, Code2, FileText, Pencil, Shield, Zap } from 'lucide-react'; import type { ComboboxOption } from '@renderer/components/ui/combobox'; @@ -109,7 +114,7 @@ export const RoleSelect = ({ if (onCustomRoleValidate) { setInternalError(onCustomRoleValidate(val)); - } else if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) { + } else if (isForbiddenTeamRole(val)) { setInternalError(t('roleSelect.reservedRole')); } else { setInternalError(null); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 122e77747f..aa3dd385c0 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -14,6 +14,7 @@ import { } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; +import { TeamMemberSettingsDialogBridge } from '@features/team-provisioning/renderer'; import { TerminalWorkspaceFloatingLauncher } from '@features/terminal-workspace/renderer'; import { classifyAnalyticsError, recordTeamStop } from '@renderer/analytics/productAnalytics'; import { api } from '@renderer/api'; @@ -420,13 +421,13 @@ function areResolvedMembersEqual( ): boolean { if (prev === next) return true; if (prev.length !== next.length) return false; - for (let i = 0; i < prev.length; i++) { const prevMember = prev[i]; const nextMember = next[i]; if ( prevMember.name !== nextMember.name || prevMember.agentId !== nextMember.agentId || + prevMember.joinedAt !== nextMember.joinedAt || prevMember.status !== nextMember.status || prevMember.currentTaskId !== nextMember.currentTaskId || prevMember.taskCount !== nextMember.taskCount || @@ -442,6 +443,8 @@ function areResolvedMembersEqual( prevMember.model !== nextMember.model || prevMember.effort !== nextMember.effort || prevMember.selectedFastMode !== nextMember.selectedFastMode || + JSON.stringify(prevMember.configuredRuntimeSettings) !== + JSON.stringify(nextMember.configuredRuntimeSettings) || prevMember.resolvedFastMode !== nextMember.resolvedFastMode || prevMember.laneId !== nextMember.laneId || prevMember.laneKind !== nextMember.laneKind || @@ -460,7 +463,6 @@ function areResolvedMembersEqual( return false; } } - return true; } @@ -610,6 +612,7 @@ type TeamMemberDetailDialogBridgeProps = Omit< ComponentProps, 'leadActivity' | 'spawnEntry' | 'runtimeEntry' >; +type EditTarget = { kind: 'team' } | { kind: 'member'; memberName: string } | null; type TeamKanbanBoardBridgeProps = Omit, 'activeTaskLogActivity'>; type TeamSidebarRailBridgeProps = Omit< ComponentProps, @@ -1426,8 +1429,7 @@ export const TeamDetailView = memo(function TeamDetailView({ const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); const [addingMemberLoading, setAddingMemberLoading] = useState(false); const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); - const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); - const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); const [launchDialogState, setLaunchDialogState] = useState<{ open: boolean; mode: TeamLaunchDialogMode; @@ -1500,7 +1502,6 @@ export const TeamDetailView = memo(function TeamDetailView({ }, [isLight] ); - // Set inert on background content when editor/graph overlay is open (a11y focus trap) useEffect(() => { const el = contentRef.current; if (!el) return; @@ -1511,7 +1512,6 @@ export const TeamDetailView = memo(function TeamDetailView({ } }, [editorOpen, graphOpen]); - // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; @@ -1540,13 +1540,11 @@ export const TeamDetailView = memo(function TeamDetailView({ mode: 'task', }); - // Active teams for conflict warning in LaunchTeamDialog const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< { teamName: string; displayName: string; projectPath: string }[] >([]); const launchDialogOpen = launchDialogState.open; - // Session loading and filtering state const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsError, setSessionsError] = useState(null); @@ -1582,7 +1580,6 @@ export const TeamDetailView = memo(function TeamDetailView({ skipMemberForLaunch, removeMember, restoreMember, - updateMemberRole, launchTeam, provisioningError, clearProvisioningError, @@ -1632,7 +1629,6 @@ export const TeamDetailView = memo(function TeamDetailView({ skipMemberForLaunch: s.skipMemberForLaunch, removeMember: s.removeMember, restoreMember: s.restoreMember, - updateMemberRole: s.updateMemberRole, launchTeam: s.launchTeam, provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, clearProvisioningError: s.clearProvisioningError, @@ -1703,7 +1699,6 @@ export const TeamDetailView = memo(function TeamDetailView({ ); const canTrackVisualizeButton = data?.teamName === teamName; - // Messages panel resize const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = useResizablePanel({ width: messagesPanelWidth, @@ -1845,7 +1840,6 @@ export const TeamDetailView = memo(function TeamDetailView({ teamName, ]); - // Fetch active teams when launch dialog opens (for conflict warning) useEffect(() => { if (!isThisTabActive || !launchDialogOpen) return; let cancelled = false; @@ -2289,45 +2283,51 @@ export const TeamDetailView = memo(function TeamDetailView({ ); const handleChangeLeadRuntime = useCallback(() => { - setEditDialogOpen(false); + setEditTarget(null); openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); - const handleRestartMember = useCallback( async (memberName: string): Promise => { await restartMember(teamName, memberName); }, [restartMember, teamName] ); - const handleSkipMemberForLaunch = useCallback( async (memberName: string): Promise => { await skipMemberForLaunch(teamName, memberName); }, [skipMemberForLaunch, teamName] ); - const handleRestoreMember = useCallback( async (memberName: string): Promise => { await restoreMember(teamName, memberName); }, [restoreMember, teamName] ); - const handleSelectMember = useCallback((member: ResolvedTeamMember) => { setSelectedMember(member); setSelectedMemberView(null); }, []); - + const handleEditMember = useCallback( + (member: ResolvedTeamMember) => { + if (member.removedAt) return; + setSelectedMember(null); + setSelectedMemberView(null); + if (isLeadMember(member)) { + handleChangeLeadRuntime(); + return; + } + setEditTarget({ kind: 'member', memberName: member.name }); + }, + [handleChangeLeadRuntime] + ); const closeSelectedMemberDialog = useCallback(() => { setSelectedMember(null); setSelectedMemberView(null); }, []); - const openTaskDetailDialog = useCallback((task: TeamTaskWithKanban) => { taskDetailDialogRef.current?.openTask(task); }, []); - const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { setSendDialogRecipient(member.name); setSendDialogDefaultText(undefined); @@ -3088,7 +3088,7 @@ export const TeamDetailView = memo(function TeamDetailView({ : t('detail.tooltips.editTeam') } disabled={isTeamProvisioning} - onClick={() => setEditDialogOpen(true)} + onClick={() => setEditTarget({ kind: 'team' })} > @@ -3286,6 +3286,7 @@ export const TeamDetailView = memo(function TeamDetailView({ onMemberClick={handleSelectMember} onSendMessage={handleSendMessageToMember} onAssignTask={handleAssignTaskToMember} + onEditMember={handleEditMember} onOpenTask={handleOpenTaskById} onRestartMember={handleRestartMember} onSkipMemberForLaunch={handleSkipMemberForLaunch} @@ -3498,22 +3499,7 @@ export const TeamDetailView = memo(function TeamDetailView({ closeSelectedMemberDialog(); openTaskDetailDialog(task); }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); - } - }} - updatingRole={updatingRoleLoading} + onEditMember={handleEditMember} onRemoveMember={() => { const name = selectedMember?.name; if (!name) return; @@ -3549,9 +3535,9 @@ export const TeamDetailView = memo(function TeamDetailView({ )} - {editDialogOpen && ( + {editTarget?.kind === 'team' && ( setEditDialogOpen(false)} + onClose={() => setEditTarget(null)} onChangeLeadRuntime={handleChangeLeadRuntime} onSaved={() => void selectTeam(teamName)} /> )} + {editTarget?.kind === 'member' ? ( + setEditTarget(null)} + onRefresh={() => selectTeam(teamName)} + onRelaunchRequired={handleChangeLeadRuntime} + /> + ) : null} + {addMemberDialogOpen && ( (null); const [saveOutcomeError, setSaveOutcomeError] = useState(null); - const [membersPendingRestartRetry, setMembersPendingRestartRetry] = useState< - Record - >({}); const wasOpenRef = useRef(false); const initializedTeamNameRef = useRef(null); const baselineSourceSnapshotRef = useRef(null); @@ -211,7 +207,6 @@ export const EditTeamDialog = ({ setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(currentMembers)); setError(null); setSaveOutcomeError(null); - setMembersPendingRestartRetry({}); initializedTeamNameRef.current = teamName; baselineSourceSnapshotRef.current = buildEditTeamSourceSnapshot({ name: currentName, @@ -295,50 +290,10 @@ export const EditTeamDialog = ({ currentMembers.some((member) => !member.removedAt && member.providerId === 'opencode'), [currentMembers, isTeamAlive, leadMember?.providerId] ); - const effectiveMembersToRestart = useMemo(() => { - const retryMembers = Object.entries(membersPendingRestartRetry).flatMap( - ([normalizedName, expectedRuntimeContractKey]) => { - const nextMember = builtMembersByName.get(normalizedName); - if (!nextMember) { - return []; - } - return getMemberRuntimeContractKey(nextMember) === expectedRuntimeContractKey - ? [nextMember.name.trim()] - : []; - } - ); - return Array.from( - new Set( - [...membersToRestart, ...retryMembers] - .map((memberName) => memberName.trim()) - .filter((memberName) => { - const nextMember = builtMembersByName.get(memberName.toLowerCase()); - return nextMember?.providerId !== 'opencode'; - }) - .filter(Boolean) - ) - ); - }, [builtMembersByName, membersPendingRestartRetry, membersToRestart]); - const openCodeMembersHandledByLiveRoster = useMemo(() => { - if (!isTeamAlive) { - return []; - } - return Array.from( - new Set( - membersToRestart - .map((memberName) => memberName.trim()) - .filter((memberName) => { - const nextMember = builtMembersByName.get(memberName.toLowerCase()); - return nextMember?.providerId === 'opencode'; - }) - .filter(Boolean) - ) - ); - }, [builtMembersByName, isTeamAlive, membersToRestart]); const liveRuntimeRefreshMemberNames = useMemo( () => - Array.from(new Set([...effectiveMembersToRestart, ...openCodeMembersHandledByLiveRoster])), - [effectiveMembersToRestart, openCodeMembersHandledByLiveRoster] + Array.from(new Set(membersToRestart.map((memberName) => memberName.trim()).filter(Boolean))), + [membersToRestart] ); const liveIdentityChanges = useMemo( () => @@ -499,45 +454,9 @@ export const EditTeamDialog = ({ })) as ResolvedTeamMember[], }); - const restartFailures: string[] = []; - const failedRestartMembers: string[] = []; - for (const memberName of effectiveMembersToRestart) { - try { - await api.teams.restartMember(teamName, memberName); - } catch (restartError) { - const detail = - restartError instanceof Error ? restartError.message : String(restartError); - failedRestartMembers.push(memberName); - restartFailures.push(`${memberName} (${detail})`); - } - } - refreshAfterSaveAttempted = true; await Promise.resolve(onSaved()); - if (restartFailures.length === 0) { - setMembersPendingRestartRetry({}); - onClose(); - return; - } - - setMembersPendingRestartRetry( - Object.fromEntries( - failedRestartMembers.flatMap((memberName) => { - const nextMember = builtMembersByName.get(memberName.trim().toLowerCase()); - if (!nextMember) { - return []; - } - return [ - [memberName.trim().toLowerCase(), getMemberRuntimeContractKey(nextMember)] as const, - ]; - }) - ) - ); - setSaveOutcomeError( - restartFailures.length === 1 - ? t('editTeam.errors.restartFailedOne', { failures: restartFailures.join(', ') }) - : t('editTeam.errors.restartFailedMany', { failures: restartFailures.join(', ') }) - ); + onClose(); } catch (e) { const message = e instanceof Error ? e.message : t('editTeam.errors.saveFailed'); if (membersSaved) { diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index dd288cc948..2ebb6cfda8 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -50,7 +50,6 @@ import { type TeamRuntimeModelOption, } from '@renderer/utils/teamModelAvailability'; import { - compareTeamModelVersionsDescending, getRuntimeAwareProviderScopedTeamModelLabel, getTeamModelSourceBadgeLabel, } from '@renderer/utils/teamModelCatalog'; @@ -104,6 +103,7 @@ import { hasFreeOpenCodeModelRoute, shouldShowOpenCodeRuntimeLoading, } from './openCodeRuntimeStatusUi'; +import { compareModelFreshness, isRecentlyReleasedModel } from './teamModelFreshness'; import { getActiveOpenCodeStickyHeadingIndex, getOpenCodeModelGridColumnCount, @@ -238,7 +238,6 @@ const OPENCODE_MODEL_GROUP_HEADING_ESTIMATE_PX = 38; const OPENCODE_MODEL_ROW_ESTIMATE_PX = 74; const OPENCODE_SCOPED_CATALOG_RETRY_DELAYS_MS = [2_000, 5_000, 10_000] as const; const MAX_BROWSER_TIMEOUT_MS = 2_147_483_647; -const NEW_MODEL_BADGE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; const OPENCODE_LOCAL_MODELS_TAB_ID = 'opencode-local-models'; const PROVIDERS: ProviderDef[] = [ { id: 'anthropic', label: 'Anthropic', comingSoon: false }, @@ -639,54 +638,6 @@ function getOpenCodeModelPricingInfo( }; } -function getModelReleaseTimestamp( - catalogModel: ProviderModelCatalogItem | null | undefined -): number | null { - const releaseDate = catalogModel?.metadata?.releaseDate?.trim(); - if (!releaseDate) { - return null; - } - const timestamp = Date.parse(releaseDate); - return Number.isFinite(timestamp) ? timestamp : null; -} - -function isRecentlyReleasedModel( - catalogModel: ProviderModelCatalogItem | null | undefined, - nowMs = Date.now() -): boolean { - const releasedAt = getModelReleaseTimestamp(catalogModel); - if (releasedAt === null) { - return false; - } - const ageMs = nowMs - releasedAt; - return ageMs >= 0 && ageMs <= NEW_MODEL_BADGE_WINDOW_MS; -} - -function compareModelFreshness( - left: { option: TeamRuntimeModelOption; catalogModel: ProviderModelCatalogItem | null }, - right: { option: TeamRuntimeModelOption; catalogModel: ProviderModelCatalogItem | null } -): number { - const releaseDateOrder = compareModelReleaseDates(left, right); - if (releaseDateOrder !== 0) { - return releaseDateOrder; - } - return compareTeamModelVersionsDescending(left.option.value, right.option.value); -} - -function compareModelReleaseDates( - left: { catalogModel: ProviderModelCatalogItem | null }, - right: { catalogModel: ProviderModelCatalogItem | null } -): number { - const leftReleasedAt = getModelReleaseTimestamp(left.catalogModel); - const rightReleasedAt = getModelReleaseTimestamp(right.catalogModel); - if (leftReleasedAt !== rightReleasedAt) { - if (leftReleasedAt === null) return 1; - if (rightReleasedAt === null) return -1; - return rightReleasedAt - leftReleasedAt; - } - return 0; -} - function shouldHydrateProviderModelCatalog( providerId: TeamProviderId, providerStatus: CliProviderStatus | null | undefined @@ -1431,6 +1382,23 @@ export const TeamModelSelector: React.FC = ({ } return t('modelSelector.defaultTooltip.runtime'); }, [effectiveProviderId, runtimeProviderStatus, t]); + const openCodeDefaultOptionLabel = useMemo(() => { + if (effectiveProviderId !== 'opencode') return t('modelSelector.defaultModel'); + const resolvedModel = + runtimeProviderStatus?.modelCatalog?.defaultLaunchModel ?? + runtimeProviderStatus?.modelCatalog?.defaultModelId ?? + null; + if (!resolvedModel) return t('modelSelector.defaultModel'); + const resolvedLabel = + resolvedModel === 'openrouter/openrouter/free' + ? 'Free Models Router' + : (getRuntimeAwareProviderScopedTeamModelLabel( + 'opencode', + resolvedModel, + runtimeProviderStatus + ) ?? resolvedModel); + return t('modelSelector.defaultWithResolved', { model: resolvedLabel }); + }, [effectiveProviderId, runtimeProviderStatus, t]); const getProviderOverrideDisabledReason = (candidateProviderId: string): string | null => { if (!isTeamProviderId(candidateProviderId)) { return null; @@ -1793,7 +1761,7 @@ export const TeamModelSelector: React.FC = ({ const pendingOptions: TeamRuntimeModelOption[] = [ { value: '', - label: t('modelSelector.defaultModel'), + label: openCodeDefaultOptionLabel, badgeLabel: t('modelSelector.defaultModel'), }, ]; @@ -1820,14 +1788,20 @@ export const TeamModelSelector: React.FC = ({ openCodeLocalModelOverlay.modelIds.has(option.value) ) : unscopedRuntimeOptions; + const presentedRuntimeOptions = + effectiveProviderId === 'opencode' + ? runtimeOptions.map((option) => + option.value.trim() ? option : { ...option, label: openCodeDefaultOptionLabel } + ) + : runtimeOptions; if ( effectiveProviderId !== 'opencode' || (openCodeLocalModelOverlay.options.length === 0 && !selectedLocalModelFallbackOption) ) { - return runtimeOptions; + return presentedRuntimeOptions; } - const optionByValue = new Map(runtimeOptions.map((option) => [option.value, option])); + const optionByValue = new Map(presentedRuntimeOptions.map((option) => [option.value, option])); if ( selectedLocalModelFallbackOption && !optionByValue.has(selectedLocalModelFallbackOption.value) @@ -1841,6 +1815,7 @@ export const TeamModelSelector: React.FC = ({ }, [ effectiveProviderId, openCodeLocalProviderLookupAuthoritative, + openCodeDefaultOptionLabel, openCodeLocalModelOverlay.modelIds, openCodeLocalModelOverlay.options, runtimeProviderStatus, @@ -2859,6 +2834,10 @@ export const TeamModelSelector: React.FC = ({ const showFreeRibbon = openCodePricingInfo?.free === true || openCodeRouteKind === 'builtin_free'; const isSelectedModel = normalizedValue === opt.value; + const optionDisplayLabel = + effectiveProviderId === 'opencode' && isSelectedModel && opt.value.trim() + ? t('modelSelector.explicitChoice', { model: opt.label }) + : opt.label; const isFlatOpenCodeCell = effectiveProviderId === 'opencode'; const flatCellBackgroundClass = 'bg-[color-mix(in_srgb,var(--color-surface-raised)_58%,var(--color-surface)_42%)]'; @@ -2871,7 +2850,11 @@ export const TeamModelSelector: React.FC = ({ data-testid="team-model-selector-model-option" aria-pressed={localModelCanAdd ? undefined : isSelectedModel} aria-disabled={!modelInteractable} - aria-label={modelButtonDescription ? `${opt.label}. ${modelButtonDescription}` : undefined} + aria-label={ + modelButtonDescription + ? `${optionDisplayLabel}. ${modelButtonDescription}` + : optionDisplayLabel + } className={cn( isFlatOpenCodeCell ? 'relative flex min-h-[58px] items-center justify-start gap-1.5 overflow-hidden border-0 border-b border-r border-[var(--color-border-subtle)] px-3 py-2 text-left text-xs font-medium transition-[background-color,color] duration-150' @@ -2938,7 +2921,7 @@ export const TeamModelSelector: React.FC = ({ )} > ['models'][number]; +const NEW_MODEL_BADGE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +function getModelReleaseTimestamp( + catalogModel: ProviderModelCatalogItem | null | undefined +): number | null { + const releaseDate = catalogModel?.metadata?.releaseDate?.trim(); + if (!releaseDate) return null; + const timestamp = Date.parse(releaseDate); + return Number.isFinite(timestamp) ? timestamp : null; +} + +export function isRecentlyReleasedModel( + catalogModel: ProviderModelCatalogItem | null | undefined, + nowMs = Date.now() +): boolean { + const releasedAt = getModelReleaseTimestamp(catalogModel); + if (releasedAt === null) return false; + const ageMs = nowMs - releasedAt; + return ageMs >= 0 && ageMs <= NEW_MODEL_BADGE_WINDOW_MS; +} + +function compareModelReleaseDates( + left: { catalogModel: ProviderModelCatalogItem | null }, + right: { catalogModel: ProviderModelCatalogItem | null } +): number { + const leftReleasedAt = getModelReleaseTimestamp(left.catalogModel); + const rightReleasedAt = getModelReleaseTimestamp(right.catalogModel); + if (leftReleasedAt === rightReleasedAt) return 0; + if (leftReleasedAt === null) return 1; + if (rightReleasedAt === null) return -1; + return rightReleasedAt - leftReleasedAt; +} + +export function compareModelFreshness( + left: { option: TeamRuntimeModelOption; catalogModel: ProviderModelCatalogItem | null }, + right: { option: TeamRuntimeModelOption; catalogModel: ProviderModelCatalogItem | null } +): number { + return ( + compareModelReleaseDates(left, right) || + compareTeamModelVersionsDescending(left.option.value, right.option.value) + ); +} diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 13beb1c02b..dea4d27fa8 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -40,8 +40,6 @@ import { HardDrive, Info, Layers3, - MessageSquare, - Plus, RotateCcw, Server, Undo2, @@ -50,6 +48,7 @@ import { import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton'; import { MemberPresenceDot } from './MemberPresenceDot'; +import { MemberQuickActions } from './MemberQuickActions'; import type { PendingMemberDeliveryState } from '../messages/messagesPanelLogic'; import type { MemberActivityTimerAnchor } from '@renderer/utils/memberActivityTimer'; @@ -107,6 +106,7 @@ interface MemberCardProps { onClick?: () => void; onSendMessage?: () => void; onAssignTask?: () => void; + onEditMember?: () => void; onRestartMember?: (memberName: string) => Promise | void; onSkipMemberForLaunch?: (memberName: string) => Promise | void; onRestoreMember?: (memberName: string) => Promise | void; @@ -571,61 +571,6 @@ const MemberRuntimeTelemetryStrip = memo(function MemberRuntimeTelemetryStrip({ ); }); -interface MemberActionButtonProps { - label: string; - children: React.ReactNode; - onClick?: () => void; -} - -const MemberActionButton = memo(function MemberActionButton({ - label, - children, - onClick, -}: MemberActionButtonProps): React.JSX.Element { - const [tooltipOpen, setTooltipOpen] = useState(false); - - return ( - - - - - {tooltipOpen ? {label} : null} - - ); -}); - -interface MemberQuickActionsProps { - onSendMessage?: () => void; - onAssignTask?: () => void; -} - -const MemberQuickActions = memo(function MemberQuickActions({ - onSendMessage, - onAssignTask, -}: MemberQuickActionsProps): React.JSX.Element { - const { t } = useAppTranslation('team'); - - return ( -
- - - - - - -
- ); -}); - interface MemberTaskProgressBadgeProps { showStartingSkeleton: boolean; memberTaskCount: number; @@ -719,6 +664,7 @@ export const MemberCard = memo(function MemberCard({ onClick, onSendMessage, onAssignTask, + onEditMember, onRestartMember, onSkipMemberForLaunch, onRestoreMember, @@ -1564,7 +1510,12 @@ export const MemberCard = memo(function MemberCard({ progressPercent={progressPercent} /> {!isRemoved && ( - + )} {canRestoreMember ? ( diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index fa80c2b87d..ae475ea6b0 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -120,8 +120,7 @@ interface MemberDetailDialogProps { onTaskClick: (task: TeamTaskWithKanban) => void; onRemoveMember?: () => void; onRestartMember?: (memberName: string) => Promise | void; - onUpdateRole?: (memberName: string, role: string | undefined) => Promise | void; - updatingRole?: boolean; + onEditMember?: (member: ResolvedTeamMember) => void; onViewMemberChanges?: (memberName: string, filePath?: string) => void; } @@ -147,8 +146,7 @@ export const MemberDetailDialog = ({ onTaskClick, onRemoveMember, onRestartMember, - onUpdateRole, - updatingRole, + onEditMember, onViewMemberChanges, }: MemberDetailDialogProps): React.JSX.Element | null => { const { t } = useAppTranslation('team'); @@ -324,10 +322,7 @@ export const MemberDetailDialog = ({ spawnUpdatedAt={spawnEntry?.updatedAt} runtimeEntry={runtimeEntry} isLaunchSettling={isLaunchSettling} - onUpdateRole={ - onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined - } - updatingRole={updatingRole} + onEditMember={onEditMember ? () => onEditMember(member) : undefined} /> diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index e61d3a17ec..a94d51a55f 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { Badge } from '@renderer/components/ui/badge'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; @@ -13,11 +14,9 @@ import { buildMemberLaunchPresentation, displayMemberName, } from '@renderer/utils/memberHelpers'; -import { isLeadMember } from '@shared/utils/leadDetection'; import { Pencil } from 'lucide-react'; import { MemberPresenceDot } from './MemberPresenceDot'; -import { MemberRoleEditor } from './MemberRoleEditor'; import type { LeadActivityState, @@ -52,8 +51,7 @@ interface MemberDetailHeaderProps { spawnFirstSpawnAcceptedAt?: string; spawnUpdatedAt?: string; isLaunchSettling?: boolean; - onUpdateRole?: (newRole: string | undefined) => Promise | void; - updatingRole?: boolean; + onEditMember?: () => void; } export const MemberDetailHeader = ({ @@ -79,11 +77,9 @@ export const MemberDetailHeader = ({ spawnFirstSpawnAcceptedAt, spawnUpdatedAt, isLaunchSettling, - onUpdateRole, - updatingRole, + onEditMember, }: MemberDetailHeaderProps): React.JSX.Element => { const { t } = useAppTranslation('team'); - const [editing, setEditing] = useState(false); const selectedTeamName = useStore((s) => s.selectedTeamName); const teamMembers = useStore((s) => selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] @@ -143,8 +139,19 @@ export const MemberDetailHeader = ({ ? (launchStatusLabel ?? presenceLabel) : presenceLabel; - const canEditRole = - !isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole; + const canEditMember = !member.removedAt && !!onEditMember; + const statusBadge = ( + + {badgeLabel} + + ); return (
@@ -163,52 +170,39 @@ export const MemberDetailHeader = ({
- {editing ? ( - { - try { - await onUpdateRole?.(newRole); - setEditing(false); - } catch { - // stay in editing mode so user can retry - } - }} - onCancel={() => setEditing(false)} - /> - ) : ( - <> - {role || 'No role'} - {canEditRole && ( - - )} - - )} - {!editing && ( - <> - - {badgeLabel} - - {/* NOTE: lead context token display disabled — usage formula is inaccurate */} - - )} - {!editing && runtimeSummary ? ( + <> + {role || 'No role'} + {canEditMember ? ( + + + + + + {isTeamProvisioning + ? t('detail.tooltips.editUnavailableProvisioning') + : t('members.actions.editRole')} + + + ) : null} + {runtimeAdvisoryTitle ? ( + + {statusBadge} + {runtimeAdvisoryTitle} + + ) : ( + statusBadge + )} + {/* NOTE: lead context token display disabled — usage formula is inaccurate */} + + {runtimeSummary ? (
{runtimeSummary}
) : null}
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 6aeb0dfaf6..e735d49f81 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -62,25 +62,25 @@ interface MemberListProps { onMemberClick?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void; onAssignTask?: (member: ResolvedTeamMember) => void; + onEditMember?: (member: ResolvedTeamMember) => void; onOpenTask?: (taskId: string) => void; onRestartMember?: (memberName: string) => Promise | void; onSkipMemberForLaunch?: (memberName: string) => Promise | void; onRestoreMember?: (memberName: string) => Promise | void; } - function areResolvedMembersEquivalent( left: readonly ResolvedTeamMember[], right: readonly ResolvedTeamMember[] ): boolean { if (left === right) return true; if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { const leftMember = left[index]; const rightMember = right[index]; if ( leftMember.name !== rightMember.name || leftMember.agentId !== rightMember.agentId || + leftMember.joinedAt !== rightMember.joinedAt || leftMember.status !== rightMember.status || leftMember.currentTaskId !== rightMember.currentTaskId || leftMember.taskCount !== rightMember.taskCount || @@ -96,6 +96,8 @@ function areResolvedMembersEquivalent( leftMember.model !== rightMember.model || leftMember.effort !== rightMember.effort || leftMember.selectedFastMode !== rightMember.selectedFastMode || + JSON.stringify(leftMember.configuredRuntimeSettings) !== + JSON.stringify(rightMember.configuredRuntimeSettings) || leftMember.resolvedFastMode !== rightMember.resolvedFastMode || leftMember.laneId !== rightMember.laneId || leftMember.laneKind !== rightMember.laneKind || @@ -114,10 +116,8 @@ function areResolvedMembersEquivalent( return false; } } - return true; } - function areMemberMcpPoliciesEquivalent( left: ResolvedTeamMember['mcpPolicy'], right: ResolvedTeamMember['mcpPolicy'] @@ -133,7 +133,6 @@ function areMemberMcpPoliciesEquivalent( (left.serverNames ?? []).every((serverName, index) => serverName === right.serverNames?.[index]) ); } - function areTaskStatusCountsMapsEquivalent( left: Map | undefined, right: Map | undefined @@ -153,7 +152,6 @@ function areTaskStatusCountsMapsEquivalent( } return true; } - function areTaskWorkIntervalsEquivalent( left: TeamTaskWithKanban['workIntervals'], right: TeamTaskWithKanban['workIntervals'] @@ -639,6 +637,7 @@ function areMemberListPropsEqual( prev.onMemberClick === next.onMemberClick && prev.onSendMessage === next.onSendMessage && prev.onAssignTask === next.onAssignTask && + prev.onEditMember === next.onEditMember && prev.onOpenTask === next.onOpenTask && prev.onRestartMember === next.onRestartMember && prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch && @@ -647,10 +646,6 @@ function areMemberListPropsEqual( ); } -// --------------------------------------------------------------------------- -// Per-member row wrapper — creates stable callbacks so MemberCard memo holds -// --------------------------------------------------------------------------- - interface MemberCardRowProps { teamName: string; member: ResolvedTeamMember; @@ -685,6 +680,7 @@ interface MemberCardRowProps { onMemberClick?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void; onAssignTask?: (member: ResolvedTeamMember) => void; + onEditMember?: (member: ResolvedTeamMember) => void; onRestartMember?: (memberName: string) => Promise | void; onSkipMemberForLaunch?: (memberName: string) => Promise | void; onRestoreMember?: (memberName: string) => Promise | void; @@ -724,6 +720,7 @@ const MemberCardRow = memo(function MemberCardRow({ onMemberClick, onSendMessage, onAssignTask, + onEditMember, onRestartMember, onSkipMemberForLaunch, onRestoreMember, @@ -742,6 +739,7 @@ const MemberCardRow = memo(function MemberCardRow({ const handleClick = useCallback(() => onMemberClick?.(member), [onMemberClick, member]); const handleSendMessage = useCallback(() => onSendMessage?.(member), [onSendMessage, member]); const handleAssignTask = useCallback(() => onAssignTask?.(member), [onAssignTask, member]); + const handleEditMember = useCallback(() => onEditMember?.(member), [onEditMember, member]); return ( buildActivityTimerRuntimeSignature(activeMembers, memberRuntimeEntries), [activeMembers, memberRuntimeEntries] ); - // Pre-compute reviewer->task map to avoid O(n*n) scan per member. const reviewTaskByMember = useMemo(() => { const result = new Map(); if (!taskMap) return result; @@ -1308,6 +1307,7 @@ export const MemberList = memo(function MemberList({ onMemberClick={onMemberClick} onSendMessage={onSendMessage} onAssignTask={onAssignTask} + onEditMember={onEditMember} onRestartMember={onRestartMember} onSkipMemberForLaunch={onSkipMemberForLaunch} onRestoreMember={onRestoreMember} diff --git a/src/renderer/components/team/members/MemberQuickActions.test.tsx b/src/renderer/components/team/members/MemberQuickActions.test.tsx new file mode 100644 index 0000000000..38b1525117 --- /dev/null +++ b/src/renderer/components/team/members/MemberQuickActions.test.tsx @@ -0,0 +1,68 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@features/localization/renderer', () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => children, + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('span', null, children), +})); + +import { MemberQuickActions } from './MemberQuickActions'; + +let host: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); +}); + +describe('MemberQuickActions', () => { + it('stops row click propagation and invokes targeted edit', () => { + const onRowClick = vi.fn(); + const onEditMember = vi.fn(); + act(() => { + root.render( +
+ +
+ ); + }); + + act(() => { + host.querySelector('[aria-label="toolApproval.settings"]')?.click(); + }); + expect(onEditMember).toHaveBeenCalledOnce(); + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('hides edit without a callback and disables it during provisioning', () => { + act(() => root.render()); + expect(host.querySelector('[aria-label="toolApproval.settings"]')).toBeNull(); + + const onEditMember = vi.fn(); + act(() => root.render()); + const edit = host.querySelector( + '[aria-label="detail.tooltips.editUnavailableProvisioning"]' + ); + expect(edit?.disabled).toBe(true); + act(() => edit?.click()); + expect(onEditMember).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/team/members/MemberQuickActions.tsx b/src/renderer/components/team/members/MemberQuickActions.tsx new file mode 100644 index 0000000000..0155e6ef52 --- /dev/null +++ b/src/renderer/components/team/members/MemberQuickActions.tsx @@ -0,0 +1,82 @@ +import { memo, useState } from 'react'; + +import { useAppTranslation } from '@features/localization/renderer'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { MessageSquare, Pencil, Plus } from 'lucide-react'; + +interface MemberActionButtonProps { + label: string; + children: React.ReactNode; + disabled?: boolean; + onClick?: () => void; +} + +export const MemberActionButton = memo(function MemberActionButton({ + label, + children, + disabled = false, + onClick, +}: MemberActionButtonProps): React.JSX.Element { + const [tooltipOpen, setTooltipOpen] = useState(false); + + return ( + + + + + {tooltipOpen ? {label} : null} + + ); +}); + +interface MemberQuickActionsProps { + onSendMessage?: () => void; + onAssignTask?: () => void; + onEditMember?: () => void; + editDisabled?: boolean; +} + +export const MemberQuickActions = memo(function MemberQuickActions({ + onSendMessage, + onAssignTask, + onEditMember, + editDisabled = false, +}: MemberQuickActionsProps): React.JSX.Element { + const { t } = useAppTranslation('team'); + + return ( +
+ + + + + + + {onEditMember ? ( + + + + ) : null} +
+ ); +}); diff --git a/src/renderer/components/team/members/MemberRoleEditor.tsx b/src/renderer/components/team/members/MemberRoleEditor.tsx index db541ebba3..83494d917d 100644 --- a/src/renderer/components/team/members/MemberRoleEditor.tsx +++ b/src/renderer/components/team/members/MemberRoleEditor.tsx @@ -3,7 +3,12 @@ import { useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { Button } from '@renderer/components/ui/button'; -import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; +import { + CUSTOM_ROLE, + isForbiddenTeamRole, + NO_ROLE, + PRESET_ROLES, +} from '@renderer/constants/teamRoles'; import { Check, Loader2, X } from 'lucide-react'; interface MemberRoleEditorProps { @@ -49,7 +54,7 @@ export const MemberRoleEditor = ({ setError(t('roleSelect.emptyCustomRole')); return; } - if (FORBIDDEN_ROLES.has(trimmed.toLowerCase())) { + if (isForbiddenTeamRole(trimmed)) { setError(t('roleSelect.reservedRole')); return; } @@ -70,7 +75,7 @@ export const MemberRoleEditor = ({ inputClassName="h-7 w-28 text-xs" customRoleError={error} onCustomRoleValidate={(val) => { - if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) return t('roleSelect.reservedRole'); + if (isForbiddenTeamRole(val)) return t('roleSelect.reservedRole'); return null; }} /> diff --git a/src/renderer/components/team/members/MembersEditorSection.test.tsx b/src/renderer/components/team/members/MembersEditorSection.test.tsx index b90223ec2c..4fbefaa74c 100644 --- a/src/renderer/components/team/members/MembersEditorSection.test.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.test.tsx @@ -69,6 +69,7 @@ vi.mock('./MemberDraftRow', () => ({ onRestore, onModelChange, agentTeamsMcpLocked, + hideActionButton, }: { member: { id: string; @@ -84,6 +85,7 @@ vi.mock('./MemberDraftRow', () => ({ onRestore?: (id: string) => void; onModelChange?: (id: string, model: string) => void; agentTeamsMcpLocked?: boolean; + hideActionButton?: boolean; }) => React.createElement( 'div', @@ -101,15 +103,17 @@ vi.mock('./MemberDraftRow', () => ({ }, member.name ), - React.createElement( - 'button', - { - type: 'button', - 'data-testid': `remove-${member.name}`, - onClick: () => onRemove?.(member.id), - }, - 'remove' - ), + hideActionButton + ? null + : React.createElement( + 'button', + { + type: 'button', + 'data-testid': `remove-${member.name}`, + onClick: () => onRemove?.(member.id), + }, + 'remove' + ), React.createElement( 'button', { @@ -154,6 +158,7 @@ function renderMembersEditor(props: { inheritedEffort?: React.ComponentProps['inheritedEffort']; limitContext?: boolean; runtimeProviderStatusById?: ReadonlyMap; + singleMemberMode?: boolean; }): { host: HTMLDivElement; onChange: ReturnType; @@ -177,6 +182,7 @@ function renderMembersEditor(props: { inheritedEffort={props.inheritedEffort} limitContext={props.limitContext} runtimeProviderStatusById={props.runtimeProviderStatusById} + singleMemberMode={props.singleMemberMode} draftKeyPrefix="worktree-test" /> ); @@ -241,6 +247,33 @@ afterEach(() => { }); describe('MembersEditorSection runtime model selection', () => { + it('preserves per-member controls while hiding roster mutations in single-member mode', () => { + const draft = createMemberDraft({ + id: 'alice', + name: 'alice', + originalName: 'alice', + model: 'claude-sonnet-4-6', + isolation: 'worktree', + }); + const { host, onChange } = renderMembersEditor({ members: [draft], singleMemberMode: true }); + + expect(host.querySelector('#teammate-worktree-default-worktree-test')).toBeNull(); + expect( + Array.from(host.querySelectorAll('button')).some((button) => + button.textContent?.includes('Add member') + ) + ).toBe(false); + expect(host.textContent).not.toContain('Members'); + expect(host.querySelector('.overflow-hidden.rounded-md.border')).toBeNull(); + + act(() => host.querySelector('[data-testid="member-alice"]')?.click()); + expect(onChange).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'alice', name: 'alice', isolation: undefined }), + ]); + onChange.mockClear(); + expect(host.querySelector('[data-testid="remove-alice"]')).toBeNull(); + }); + it('clears stale teammate effort immediately when selecting a model without effort support', () => { const { host, onChange } = renderMembersEditor({ inheritedProviderId: 'anthropic', diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index a993efd40f..8f618428e0 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -164,6 +164,8 @@ export interface MembersEditorSectionProps { teammateWorktreeDefault?: boolean; worktreeIsolationDisabledReason?: string | null; onTeammateWorktreeDefaultChange?: (enabled: boolean) => void; + /** Restricts the editor to one existing member and locks roster-level mutations. */ + singleMemberMode?: boolean; } export const MembersEditorSection = ({ @@ -212,6 +214,7 @@ export const MembersEditorSection = ({ teammateWorktreeDefault = false, worktreeIsolationDisabledReason, onTeammateWorktreeDefaultChange, + singleMemberMode = false, }: MembersEditorSectionProps): React.JSX.Element => { const { t } = useAppTranslation('team'); const [jsonEditorOpen, setJsonEditorOpen] = useState(false); @@ -494,69 +497,73 @@ export const MembersEditorSection = ({ [members, memberColorMap] ); const isFlatRoster = layoutVariant === 'flat'; - const editorActions = !hideContent ? ( -
- - {showJsonEditor && !jsonEditorOpen ? ( - - ) : null} -
- ) : null; - const masterRosterControls = showWorktreeIsolationControls ? ( - <> -
- updateTeammateWorktreeDefault(checked === true)} - /> -
-
- updateAgentTeamsMcpLock(checked === true)} - /> -
+
+ updateAgentTeamsMcpLock(checked === true)} + /> + +
+ + ) : null; return (
-
- - {!isFlatRoster ? editorActions : null} -
+ {!singleMemberMode ? ( +
+ + {!isFlatRoster ? editorActions : null} +
+ ) : null} {isFlatRoster ? (
@@ -584,12 +591,12 @@ export const MembersEditorSection = ({ ) : null}
- {showWorktreeIsolationControls && !isFlatRoster ? ( + {showWorktreeIsolationControls && !isFlatRoster && !singleMemberMode ? (
{activeMembers.map((member, index) => ( @@ -615,6 +622,7 @@ export const MembersEditorSection = ({ onRoleChange={updateMemberRole} onCustomRoleChange={updateMemberCustomRole} onRemove={removeMember} + hideActionButton={singleMemberMode} showWorkflow={showWorkflow} onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined} onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined} @@ -639,7 +647,10 @@ export const MembersEditorSection = ({ teamSuggestions={teamSuggestions} onWorkflowSuggestionsNeeded={onWorkflowSuggestionsNeeded} lockProviderModel={lockProviderModel} - lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())} + lockIdentity={ + (singleMemberMode || lockExistingMemberIdentity) && + Boolean(member.originalName?.trim()) + } identityLockReason={identityLockReason} modelLockReason={modelLockReason} warningText={memberWarningById?.[member.id] ?? null} diff --git a/src/renderer/constants/teamRoles.ts b/src/renderer/constants/teamRoles.ts index cd1ec048a1..f9be2b96df 100644 --- a/src/renderer/constants/teamRoles.ts +++ b/src/renderer/constants/teamRoles.ts @@ -17,4 +17,8 @@ export const CUSTOM_ROLE = '__custom__'; export const NO_ROLE = '__none__'; /** Roles that cannot be assigned manually (reserved for system use). */ -export const FORBIDDEN_ROLES = new Set(['lead', 'team-lead', 'orchestrator']); +export const FORBIDDEN_ROLES = new Set(['lead', 'team lead', 'team-lead', 'orchestrator']); + +export function isForbiddenTeamRole(value: string): boolean { + return FORBIDDEN_ROLES.has(value.trim().toLowerCase().replace(/\s+/g, ' ')); +} diff --git a/src/renderer/store/team/teamResolvedMembers.ts b/src/renderer/store/team/teamResolvedMembers.ts index dbeb4db96d..afb70a326f 100644 --- a/src/renderer/store/team/teamResolvedMembers.ts +++ b/src/renderer/store/team/teamResolvedMembers.ts @@ -143,6 +143,7 @@ function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMem fallbackMembers.push({ name, agentId: member.agentId, + joinedAt: member.joinedAt, currentTaskId: currentTask?.id ?? null, taskCount: ownedTasks.length, color: member.color ?? getMemberColorByName(name), @@ -156,6 +157,13 @@ function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMem effort: member.effort, mcpPolicy: member.mcpPolicy, selectedFastMode: member.fastMode, + configuredRuntimeSettings: { + providerId: member.providerId, + providerBackendId: member.providerBackendId, + model: member.model, + effort: member.effort, + fastMode: member.fastMode, + }, cwd: member.cwd, removedAt: member.removedAt, }); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index e6c3f71307..156b9734b9 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -137,6 +137,7 @@ import type { OrganizationsElectronApi } from '@features/organizations/contracts import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts'; import type { TeamImportApi } from '@features/team-import/contracts'; +import type { TeamMemberSettingsApi } from '@features/team-provisioning/contracts'; import type { TerminalWorkspaceElectronApi } from '@features/terminal-workspace/contracts'; import type { TokenUsageElectronApi } from '@features/token-usage/contracts'; import type { WorkspaceTrustElectronApi } from '@features/workspace-trust/contracts'; @@ -507,12 +508,11 @@ export interface HttpServerAPI { stop: () => Promise; getStatus: () => Promise; } - // ============================================================================= // Teams API // ============================================================================= -export interface TeamsAPI { +export interface TeamsAPI extends TeamMemberSettingsApi { list: () => Promise; getData: (teamName: string, options?: TeamGetDataOptions) => Promise; getTaskChangePresence: (teamName: string) => Promise>; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 0ccabc24bb..4543bf9894 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -882,8 +882,7 @@ export type UpdateKanbanPatch = | { op: 'set_column'; column: Extract } | { op: 'remove' } | { op: 'request_changes'; comment?: string; taskRefs?: TaskRef[] }; - -export interface ResolvedTeamMember { +export interface ResolvedTeamMember extends Pick { name: string; agentId?: string; status: MemberStatus; @@ -902,6 +901,7 @@ export interface ResolvedTeamMember { effort?: EffortLevel; mcpPolicy?: TeamMemberMcpPolicy; selectedFastMode?: TeamFastMode; + configuredRuntimeSettings?: TeamMemberConfiguredRuntimeSettings; resolvedFastMode?: boolean; laneId?: string; laneKind?: 'primary' | 'secondary'; @@ -912,7 +912,6 @@ export interface ResolvedTeamMember { runtimeAdvisory?: MemberRuntimeAdvisory; removedAt?: number; } - export interface MemberRuntimeAdvisory { kind: 'sdk_retrying' | 'api_error'; observedAt: string; @@ -932,9 +931,7 @@ export interface MemberRuntimeAdvisory { message?: string; statusCode?: number; } - export type OpenCodeRuntimeDeliveryUserVisibleState = 'none' | 'checking' | 'warning' | 'error'; - export interface OpenCodeRuntimeDeliveryUserVisibleImpact { state: OpenCodeRuntimeDeliveryUserVisibleState; reasonCode?: MemberRuntimeAdvisory['reasonCode']; @@ -942,7 +939,6 @@ export interface OpenCodeRuntimeDeliveryUserVisibleImpact { observedAt?: string; nextReviewAt?: string; } - export interface TeamProcess { id: string; port?: number; @@ -955,8 +951,7 @@ export interface TeamProcess { registeredAt: string; stoppedAt?: string; } - -export interface TeamMemberSnapshot { +export interface TeamMemberSnapshot extends Pick { name: string; agentId?: string; currentTaskId: string | null; @@ -972,6 +967,7 @@ export interface TeamMemberSnapshot { effort?: EffortLevel; mcpPolicy?: TeamMemberMcpPolicy; selectedFastMode?: TeamFastMode; + configuredRuntimeSettings?: TeamMemberConfiguredRuntimeSettings; resolvedFastMode?: boolean; laneId?: string; laneKind?: 'primary' | 'secondary'; @@ -982,6 +978,10 @@ export interface TeamMemberSnapshot { runtimeAdvisory?: MemberRuntimeAdvisory; removedAt?: number; } +export type TeamMemberConfiguredRuntimeSettings = Pick< + TeamMember, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' +>; export interface MemberActivityMetaEntry { memberName: string; diff --git a/src/shared/utils/leadDetection.ts b/src/shared/utils/leadDetection.ts index 0cbf2e7249..19611ff2d9 100644 --- a/src/shared/utils/leadDetection.ts +++ b/src/shared/utils/leadDetection.ts @@ -26,9 +26,16 @@ export function isLeadAgentType(agentType: string | undefined | null): boolean { * Returns true if the member is a team lead, checking both agentType * and the conventional "team-lead" name as a fallback. */ -export function isLeadMember(member: { agentType?: unknown; name?: unknown }): boolean { +export function isLeadMember(member: { + agentType?: unknown; + name?: unknown; + role?: unknown; +}): boolean { const agentType = typeof member.agentType === 'string' ? member.agentType : null; if (isLeadAgentType(agentType)) return true; const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : ''; - return name === 'team-lead'; + if (name === 'team-lead') return true; + if (agentType?.trim()) return false; + const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : ''; + return role.replace(/\s+/g, ' ') === 'team lead'; } diff --git a/test/features/team-provisioning/preload/createTeamMemberSettingsBridge.test.ts b/test/features/team-provisioning/preload/createTeamMemberSettingsBridge.test.ts new file mode 100644 index 0000000000..084aee313f --- /dev/null +++ b/test/features/team-provisioning/preload/createTeamMemberSettingsBridge.test.ts @@ -0,0 +1,61 @@ +import { + TEAM_UPDATE_MEMBER_SETTINGS, + type UpdateMemberSettingsRequest, + type UpdateMemberSettingsResult, +} from '@features/team-provisioning/contracts'; +import { + createTeamMemberSettingsBridge, + type InvokeIpcWithResult, +} from '@features/team-provisioning/preload'; +import { describe, expect, it, vi } from 'vitest'; + +const request: UpdateMemberSettingsRequest = { + commandId: 'command-1', + idempotencyKey: 'member-settings-1', + teamName: 'team-a', + memberName: 'worker', + expectedFingerprint: 'fingerprint-before', + settings: { + role: 'Implement the assigned slice', + workflow: null, + isolation: 'worktree', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.6-sol', + effort: 'medium', + fastMode: 'inherit', + mcpPolicy: null, + }, +}; + +describe('createTeamMemberSettingsBridge', () => { + it('invokes the member settings channel once with the unchanged request', async () => { + const result: UpdateMemberSettingsResult = { + outcome: 'completed', + effect: 'persisted_only', + memberName: 'worker', + previousFingerprint: 'fingerprint-before', + currentFingerprint: 'fingerprint-after', + replayed: false, + }; + const invokeMock = vi.fn(async () => result); + const invoke = invokeMock as InvokeIpcWithResult; + const bridge = createTeamMemberSettingsBridge(invoke); + + await expect(bridge.updateMemberSettings(request)).resolves.toBe(result); + + expect(invokeMock).toHaveBeenCalledOnce(); + expect(invokeMock).toHaveBeenCalledWith(TEAM_UPDATE_MEMBER_SETTINGS, request); + }); + + it('propagates invoker errors to the caller', async () => { + const failure = new Error('ipc failed'); + const invokeMock = vi.fn(async () => { + throw failure; + }); + const invoke = invokeMock as InvokeIpcWithResult; + const bridge = createTeamMemberSettingsBridge(invoke); + + await expect(bridge.updateMemberSettings(request)).rejects.toBe(failure); + }); +}); diff --git a/test/main/features/team-provisioning/LegacyMemberSettingsAdapters.test.ts b/test/main/features/team-provisioning/LegacyMemberSettingsAdapters.test.ts new file mode 100644 index 0000000000..4933af58c8 --- /dev/null +++ b/test/main/features/team-provisioning/LegacyMemberSettingsAdapters.test.ts @@ -0,0 +1,459 @@ +import { createMemberSettingsFingerprint } from '@features/team-provisioning/core/domain/memberSettingsPolicy'; +import { LegacyMemberSettingsLifecycleAdapter } from '@features/team-provisioning/main/adapters/output/LegacyMemberSettingsLifecycleAdapter'; +import { LegacyMemberSettingsMutationGateAdapter } from '@features/team-provisioning/main/adapters/output/LegacyMemberSettingsMutationGateAdapter'; +import { LegacyMemberSettingsRepositoryAdapter } from '@features/team-provisioning/main/adapters/output/LegacyMemberSettingsRepositoryAdapter'; +import { fingerprintResolvedMember } from '@features/team-provisioning/renderer/utils/memberSettingsPresentation'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EditableMemberSettings } from '@features/team-provisioning/contracts/memberSettings'; +import type { TeamMembersMetaFile } from '@main/services/team/TeamMembersMetaStore'; + +const cleared: EditableMemberSettings = { + role: null, + workflow: null, + isolation: null, + providerId: null, + providerBackendId: null, + model: null, + effort: null, + fastMode: null, + mcpPolicy: null, +}; + +function fixture() { + let meta: TeamMembersMetaFile = { + version: 1, + providerBackendId: 'cli-sdk', + members: [ + { + name: 'Alice', + role: 'builder', + workflow: 'ship', + isolation: 'worktree', + providerId: 'codex', + providerBackendId: 'cli-sdk', + model: 'gpt-old', + effort: 'high', + fastMode: 'on', + mcpPolicy: { mode: 'appOnly' }, + agentId: 'agent-a', + joinedAt: 11, + color: 'blue', + }, + { name: 'Bob', role: 'reviewer', agentId: 'agent-b', color: 'green' }, + ], + }; + let config = { + name: 'Team', + description: 'preserve', + members: [ + { + name: 'ALICE', + role: 'builder', + workflow: 'ship', + isolation: 'worktree', + provider: 'codex', + providerId: 'codex', + providerBackendId: 'cli-sdk', + model: 'gpt-old', + effort: 'high', + fastMode: 'on', + mcpPolicy: { mode: 'appOnly' }, + agentId: 'agent-a', + joinedAt: 11, + runtimePid: 9001, + subscriptions: ['task'], + }, + { name: 'Bob', role: 'reviewer', agentId: 'agent-b', runtimePid: 9002 }, + ], + }; + const invalidateCaches = vi.fn(); + let failConfigWrite = false; + const adapter = new LegacyMemberSettingsRepositoryAdapter({ + membersMetaStore: { + getMeta: async () => structuredClone(meta), + writeMembers: async (_teamName, members, options) => { + meta = { + version: 1, + providerBackendId: options?.providerBackendId, + members: structuredClone(members), + }; + }, + }, + readConfigJson: async () => JSON.stringify(config), + writeConfigJsonAtomic: async (_teamName, contents) => { + if (failConfigWrite) throw new Error('config write failed'); + config = JSON.parse(contents) as typeof config; + }, + withConfigLock: async (_teamName, operation) => operation(), + readLeadProviderId: async () => 'codex', + teamExists: async () => true, + isTeamAlive: () => true, + invalidateCaches, + }); + return { + adapter, + invalidateCaches, + get meta() { + return meta; + }, + get config() { + return config; + }, + set config(value) { + config = value; + }, + set meta(value) { + meta = value; + }, + failConfigWrite() { + failConfigWrite = true; + }, + }; +} + +describe('LegacyMemberSettingsRepositoryAdapter', () => { + it('reads and saves an offline meta-only target without creating config.json', async () => { + let meta: TeamMembersMetaFile = { + version: 1, + members: [{ name: 'DraftWorker', role: 'draft', providerId: 'opencode' }], + }; + const writeConfigJsonAtomic = vi.fn(); + const adapter = new LegacyMemberSettingsRepositoryAdapter({ + membersMetaStore: { + getMeta: async () => structuredClone(meta), + writeMembers: async (_teamName, members) => { + meta = { version: 1, members: structuredClone(members) }; + }, + }, + readConfigJson: async () => null, + writeConfigJsonAtomic, + withConfigLock: async (_teamName, operation) => operation(), + readLeadProviderId: async () => 'codex', + teamExists: async () => true, + isTeamAlive: () => false, + invalidateCaches: vi.fn(), + }); + const before = await adapter.findTarget('draft-team', 'draftworker'); + + expect(before).toMatchObject({ + name: 'DraftWorker', + teamIsAlive: false, + teamIsMixed: true, + runtimeLane: 'opencode_secondary', + }); + await expect( + adapter.applyTarget({ + teamName: 'draft-team', + memberName: 'DRAFTWORKER', + expectedFingerprint: createMemberSettingsFingerprint(before!), + settings: { ...cleared, role: 'ready', providerId: 'opencode' }, + }) + ).resolves.toMatchObject({ outcome: 'applied' }); + expect(meta.members).toEqual([ + expect.objectContaining({ name: 'DraftWorker', role: 'ready', providerId: 'opencode' }), + ]); + expect(writeConfigJsonAtomic).not.toHaveBeenCalled(); + }); + + it('treats tombstones in either store as authoritative for targets and mixed policy', async () => { + const state = fixture(); + state.meta.members[1] = { + ...state.meta.members[1]!, + providerId: 'opencode', + }; + state.config.members[1] = { + ...state.config.members[1]!, + providerId: 'opencode', + removedAt: Date.now(), + } as unknown as (typeof state.config.members)[number]; + await expect(state.adapter.findTarget('team-a', 'Alice')).resolves.toMatchObject({ + teamIsMixed: false, + }); + + delete (state.config.members[1] as { removedAt?: number }).removedAt; + state.meta.members[1] = { ...state.meta.members[1]!, removedAt: Date.now() }; + await expect(state.adapter.findTarget('team-a', 'Alice')).resolves.toMatchObject({ + teamIsMixed: false, + }); + + state.meta.members[0] = { ...state.meta.members[0]!, removedAt: Date.now() }; + await expect(state.adapter.findTarget('team-a', 'Alice')).resolves.toBeNull(); + + delete state.meta.members[0]!.removedAt; + state.config.members[0] = { + ...state.config.members[0]!, + removedAt: Date.now(), + } as unknown as (typeof state.config.members)[number]; + await expect(state.adapter.findTarget('team-a', 'Alice')).resolves.toBeNull(); + }); + + it('clears exact target settings in meta and config while preserving siblings and runtime fields', async () => { + const state = fixture(); + const before = await state.adapter.findTarget('team-a', 'alice'); + expect(before).not.toBeNull(); + const bobMeta = structuredClone(state.meta.members[1]); + const bobConfig = structuredClone(state.config.members[1]); + + const result = await state.adapter.applyTarget({ + teamName: 'team-a', + memberName: 'aLiCe', + expectedFingerprint: createMemberSettingsFingerprint(before!), + settings: cleared, + }); + + expect(result.outcome).toBe('applied'); + const metaAlice = state.meta.members.find((member) => member.name === 'Alice')!; + const configAlice = state.config.members.find((member) => member.name === 'ALICE')!; + for (const key of Object.keys(cleared)) { + expect(metaAlice).not.toHaveProperty(key); + expect(configAlice).not.toHaveProperty(key); + } + expect(configAlice).not.toHaveProperty('provider'); + expect(metaAlice).toMatchObject({ agentId: 'agent-a', joinedAt: 11, color: 'blue' }); + expect(configAlice).toMatchObject({ runtimePid: 9001, subscriptions: ['task'] }); + expect(state.meta.members[1]).toEqual(bobMeta); + expect(state.config.members[1]).toEqual(bobConfig); + expect(state.invalidateCaches).toHaveBeenCalledOnce(); + }); + + it('restores divergent raw metadata and config target snapshots exactly', async () => { + const state = fixture(); + state.meta.members[0] = { ...state.meta.members[0]!, model: 'meta-model', effort: 'low' }; + state.config.members[0] = { + ...state.config.members[0]!, + agentId: 'config-agent', + joinedAt: 22, + provider: 'anthropic', + providerId: 'codex', + model: 'config-model', + effort: 'high', + } as (typeof state.config.members)[number]; + const originalMetaTarget = structuredClone(state.meta.members[0]); + const originalConfigTarget = structuredClone(state.config.members[0]); + const before = (await state.adapter.findTarget('team-a', 'alice'))!; + expect(before).toMatchObject({ + agentId: 'config-agent', + joinedAt: 22, + settings: { model: 'config-model', effort: 'high', providerId: 'codex' }, + }); + expect( + fingerprintResolvedMember({ + name: 'ALICE', + agentId: 'config-agent', + joinedAt: 22, + status: 'idle', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + role: 'builder', + workflow: 'ship', + isolation: 'worktree', + providerId: 'codex', + providerBackendId: 'cli-sdk', + model: 'config-model', + effort: 'high', + selectedFastMode: 'on', + configuredRuntimeSettings: { + providerId: 'codex', + providerBackendId: 'cli-sdk', + model: 'config-model', + effort: 'high', + fastMode: 'on', + }, + mcpPolicy: { mode: 'appOnly' }, + }) + ).toBe(createMemberSettingsFingerprint(before)); + const applied = await state.adapter.applyTarget({ + teamName: 'team-a', + memberName: 'Alice', + expectedFingerprint: createMemberSettingsFingerprint(before), + settings: { ...cleared, role: 'temporary' }, + }); + expect(applied.outcome).toBe('applied'); + if (applied.outcome !== 'applied') return; + + await expect( + state.adapter.restoreTarget({ + teamName: 'team-a', + memberName: 'Alice', + expectedFingerprint: createMemberSettingsFingerprint(applied.snapshot), + snapshot: before, + rollbackToken: applied.rollbackToken, + }) + ).resolves.toBe(true); + + expect(state.meta.members[0]).toEqual(originalMetaTarget); + expect(state.config.members[0]).toEqual(originalConfigTarget); + }); + + it('rolls back only target metadata when config persistence fails', async () => { + const state = fixture(); + const original = structuredClone(state.meta); + const before = await state.adapter.findTarget('team-a', 'alice'); + state.failConfigWrite(); + + await expect( + state.adapter.applyTarget({ + teamName: 'team-a', + memberName: 'Alice', + expectedFingerprint: createMemberSettingsFingerprint(before!), + settings: cleared, + }) + ).rejects.toThrow('Config update failed'); + + expect(state.meta).toEqual(original); + expect(state.invalidateCaches).toHaveBeenCalledOnce(); + }); + + it('restore changes only target settings and preserves concurrently updated siblings', async () => { + const state = fixture(); + const before = (await state.adapter.findTarget('team-a', 'alice'))!; + const applied = await state.adapter.applyTarget({ + teamName: 'team-a', + memberName: 'Alice', + expectedFingerprint: createMemberSettingsFingerprint(before), + settings: { ...cleared, role: 'new role' }, + }); + expect(applied.outcome).toBe('applied'); + if (applied.outcome !== 'applied') return; + + state.meta.members[1] = { ...state.meta.members[1]!, role: 'concurrent meta' }; + state.config.members[1] = { ...state.config.members[1]!, role: 'concurrent config' }; + const restored = await state.adapter.restoreTarget({ + teamName: 'team-a', + memberName: 'alice', + expectedFingerprint: createMemberSettingsFingerprint(applied.snapshot), + snapshot: before, + rollbackToken: applied.rollbackToken, + }); + + expect(restored).toBe(true); + expect(state.meta.members[1]).toMatchObject({ role: 'concurrent meta' }); + expect(state.config.members[1]).toMatchObject({ role: 'concurrent config' }); + expect(state.meta.members[0]).toMatchObject({ role: 'builder', agentId: 'agent-a' }); + expect(state.config.members[0]).toMatchObject({ role: 'builder', runtimePid: 9001 }); + }); + + it.each(['metadata', 'config'] as const)( + 'restores exact target entry presence when %s originally had no target', + async (missingStore) => { + const state = fixture(); + if (missingStore === 'metadata') { + state.meta = { ...state.meta, members: state.meta.members.slice(1) }; + } else { + state.config = { ...state.config, members: state.config.members.slice(1) }; + } + const before = (await state.adapter.findTarget('team-a', 'alice'))!; + const applied = await state.adapter.applyTarget({ + teamName: 'team-a', + memberName: 'Alice', + expectedFingerprint: createMemberSettingsFingerprint(before), + settings: { ...cleared, role: 'new role' }, + }); + expect(applied.outcome).toBe('applied'); + if (applied.outcome !== 'applied') return; + + await expect( + state.adapter.restoreTarget({ + teamName: 'team-a', + memberName: 'Alice', + expectedFingerprint: createMemberSettingsFingerprint(applied.snapshot), + snapshot: before, + rollbackToken: applied.rollbackToken, + }) + ).resolves.toBe(true); + + expect(state.meta.members.some((member) => member.name.toLowerCase() === 'alice')).toBe( + missingStore !== 'metadata' + ); + expect( + state.config.members.some((member) => String(member.name).toLowerCase() === 'alice') + ).toBe(missingStore !== 'config'); + } + ); +}); + +describe('LegacyMemberSettingsLifecycleAdapter', () => { + it('keeps persisted settings without attach when the team stopped before lifecycle', async () => { + const attachLiveRosterMember = vi.fn(async () => undefined); + const adapter = new LegacyMemberSettingsLifecycleAdapter({ + attachLiveRosterMember, + isTeamAlive: () => false, + }); + const snapshot = (await fixture().adapter.findTarget('team-a', 'Alice'))!; + + await expect( + adapter.applyEffect({ + teamName: 'team-a', + before: snapshot, + after: snapshot, + action: 'restart_member', + }) + ).resolves.toBe('persisted_only'); + expect(attachLiveRosterMember).not.toHaveBeenCalled(); + }); + + it('performs exactly one factual attach for primary and OpenCode lane effects', async () => { + const attachLiveRosterMember = vi.fn(async () => undefined); + const adapter = new LegacyMemberSettingsLifecycleAdapter({ + attachLiveRosterMember, + isTeamAlive: () => true, + }); + const snapshot = (await fixture().adapter.findTarget('team-a', 'Alice'))!; + + await expect( + adapter.applyEffect({ + teamName: 'team-a', + before: snapshot, + after: snapshot, + action: 'restart_member', + }) + ).resolves.toBe('member_restart_started'); + expect(attachLiveRosterMember).toHaveBeenCalledTimes(1); + expect(attachLiveRosterMember).toHaveBeenLastCalledWith('team-a', 'ALICE', { + reason: 'member_updated', + }); + + attachLiveRosterMember.mockClear(); + await expect( + adapter.applyEffect({ + teamName: 'team-a', + before: snapshot, + after: snapshot, + action: 'restart_opencode_lane', + }) + ).resolves.toBe('opencode_lane_restart_started'); + expect(attachLiveRosterMember).toHaveBeenCalledTimes(1); + + attachLiveRosterMember.mockClear(); + await expect( + adapter.restore({ + teamName: 'team-a', + before: snapshot, + after: snapshot, + attemptedAction: 'restart_member', + }) + ).resolves.toBe(true); + expect(attachLiveRosterMember).toHaveBeenCalledTimes(1); + expect(attachLiveRosterMember).toHaveBeenCalledWith('team-a', 'ALICE', { + reason: 'member_updated', + }); + }); +}); + +describe('LegacyMemberSettingsMutationGateAdapter', () => { + it('rejects an already occupied team operation without running the mutation', async () => { + const operation = vi.fn(async () => 'result'); + const adapter = new LegacyMemberSettingsMutationGateAdapter({ + runLiveRosterMutation: vi.fn(), + tryRunLiveRosterMutation: vi.fn(async () => false), + }); + + await expect(adapter.runExclusive('team-a', operation)).rejects.toThrow( + 'Team mutation is already in progress' + ); + expect(operation).not.toHaveBeenCalled(); + }); +}); diff --git a/test/main/features/team-provisioning/TeamMemberSettingsComposition.test.ts b/test/main/features/team-provisioning/TeamMemberSettingsComposition.test.ts new file mode 100644 index 0000000000..2b401c8a2b --- /dev/null +++ b/test/main/features/team-provisioning/TeamMemberSettingsComposition.test.ts @@ -0,0 +1,335 @@ +import { ApplicationCommandRunOutcome } from '@features/application-command-ledger'; +import { createMemberSettingsFingerprint } from '@features/team-provisioning/core/domain/memberSettingsPolicy'; +import { createTeamMemberSettingsFeature } from '@features/team-provisioning/main/composition/createTeamMemberSettingsFeature'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ApplicationCommandRunner } from '@features/application-command-ledger'; +import type { UpdateMemberSettingsRequest } from '@features/team-provisioning/contracts/memberSettings'; +import type { MemberSettingsRepositoryPort } from '@features/team-provisioning/core/application/ports/UpdateMemberSettingsPorts'; +import type { MemberSettingsTargetSnapshot } from '@features/team-provisioning/core/domain/memberSettingsPolicy'; + +function snapshot(role: string): MemberSettingsTargetSnapshot { + return { + name: 'Alice', + agentType: 'worker', + agentId: 'agent-a', + joinedAt: 1, + settings: { + role, + workflow: null, + isolation: null, + providerId: 'codex', + providerBackendId: null, + model: null, + effort: null, + fastMode: null, + mcpPolicy: null, + }, + teamIsAlive: true, + leadProviderId: 'codex', + teamIsMixed: false, + runtimeLane: 'primary', + }; +} + +describe('createTeamMemberSettingsFeature', () => { + it('replays a ledger duplicate without applying or attaching a second time', async () => { + let current = snapshot('old'); + const applyTarget = vi.fn(async (input) => { + current = { ...current, settings: input.settings }; + return { outcome: 'applied', snapshot: current, rollbackToken: null }; + }); + const repository: MemberSettingsRepositoryPort = { + findTarget: async () => current, + classifyMissingTarget: async () => 'member_not_found', + applyTarget, + restoreTarget: async () => true, + }; + const stored = new Map(); + const commandRunner = { + run: vi.fn(async (input: { idempotencyKey: string }, execute: () => Promise) => { + if (stored.has(input.idempotencyKey)) { + return { + outcome: ApplicationCommandRunOutcome.Replayed, + result: stored.get(input.idempotencyKey), + }; + } + const result = await execute(); + stored.set(input.idempotencyKey, result); + return { outcome: ApplicationCommandRunOutcome.Executed, result }; + }), + } as unknown as ApplicationCommandRunner; + const attachLiveRosterMember = vi.fn(async () => undefined); + const feature = createTeamMemberSettingsFeature({ + repository, + commandRunner, + mutationSource: { + runLiveRosterMutation: async (_teamName, operation) => { + await operation(); + }, + }, + lifecycleSource: { attachLiveRosterMember, isTeamAlive: () => true }, + }); + const request: UpdateMemberSettingsRequest = { + commandId: 'command-1', + idempotencyKey: 'idem-1', + teamName: ' Team-A ', + memberName: ' ALICE ', + expectedFingerprint: createMemberSettingsFingerprint(current), + settings: { ...current.settings, role: 'new' }, + }; + + const first = await feature.updateMemberSettings(request); + const replay = await feature.updateMemberSettings(request); + + expect(first).toMatchObject({ replayed: false }); + expect(replay).toEqual({ ...first, replayed: true }); + expect(applyTarget).toHaveBeenCalledTimes(1); + expect(attachLiveRosterMember).toHaveBeenCalledTimes(1); + expect(commandRunner.run).toHaveBeenCalledTimes(2); + }); + + it('marks a process-local duplicate as replayed without a second lifecycle effect', async () => { + let current = snapshot('old'); + const applyTarget = vi.fn(async (input) => { + current = { ...current, settings: input.settings }; + return { outcome: 'applied', snapshot: current, rollbackToken: null }; + }); + const attachLiveRosterMember = vi.fn(async () => undefined); + const feature = createTeamMemberSettingsFeature({ + repository: { + findTarget: async () => current, + classifyMissingTarget: async () => 'member_not_found', + applyTarget, + restoreTarget: async () => true, + }, + mutationSource: { runLiveRosterMutation: async (_team, operation) => operation() }, + lifecycleSource: { attachLiveRosterMember, isTeamAlive: () => true }, + }); + const request: UpdateMemberSettingsRequest = { + commandId: 'command-local', + idempotencyKey: 'idem-local', + teamName: 'team-a', + memberName: 'alice', + expectedFingerprint: createMemberSettingsFingerprint(current), + settings: { ...current.settings, role: 'new' }, + }; + + const first = await feature.updateMemberSettings(request); + const replay = await feature.updateMemberSettings(request); + + expect(first.replayed).toBe(false); + expect(replay).toEqual({ ...first, replayed: true }); + expect(applyTarget).toHaveBeenCalledOnce(); + expect(attachLiveRosterMember).toHaveBeenCalledOnce(); + }); + + it('retains process-local identities after failure while allowing only the same payload to retry', async () => { + let current = snapshot('old'); + let attempt = 0; + const applyTarget = vi.fn(async (input) => { + attempt += 1; + if (attempt === 1) throw new Error('temporary failure'); + current = { ...current, settings: input.settings }; + return { outcome: 'applied', snapshot: current, rollbackToken: null }; + }); + const feature = createTeamMemberSettingsFeature({ + repository: { + findTarget: async () => current, + classifyMissingTarget: async () => 'member_not_found', + applyTarget, + restoreTarget: async () => true, + }, + mutationSource: { runLiveRosterMutation: async (_team, operation) => operation() }, + lifecycleSource: { attachLiveRosterMember: async () => undefined, isTeamAlive: () => true }, + }); + const request: UpdateMemberSettingsRequest = { + commandId: 'command-retry', + idempotencyKey: 'idem-retry', + teamName: 'team-a', + memberName: 'alice', + expectedFingerprint: createMemberSettingsFingerprint(current), + settings: { ...current.settings, role: 'new' }, + }; + + await expect(feature.updateMemberSettings(request)).rejects.toThrow('temporary failure'); + await expect( + feature.updateMemberSettings({ ...request, settings: { ...request.settings, role: 'other' } }) + ).rejects.toThrow('identity was reused'); + await expect(feature.updateMemberSettings(request)).resolves.toMatchObject({ + outcome: 'completed', + replayed: false, + }); + await expect(feature.updateMemberSettings(request)).resolves.toMatchObject({ + outcome: 'completed', + replayed: true, + }); + expect(applyTarget).toHaveBeenCalledTimes(2); + }); + + it('evicts the oldest settled fallback identity instead of permanently exhausting capacity', async () => { + let current = { ...snapshot('role-0'), teamIsAlive: false }; + const applyTarget = vi.fn(async (input) => { + current = { ...current, settings: input.settings }; + return { outcome: 'applied', snapshot: current, rollbackToken: null }; + }); + const feature = createTeamMemberSettingsFeature({ + repository: { + findTarget: async () => current, + classifyMissingTarget: async () => 'member_not_found', + applyTarget, + restoreTarget: async () => true, + }, + mutationSource: { runLiveRosterMutation: async (_team, operation) => operation() }, + lifecycleSource: { + attachLiveRosterMember: async () => undefined, + isTeamAlive: () => false, + }, + }); + const firstRequest: UpdateMemberSettingsRequest = { + commandId: 'bounded-command-0', + idempotencyKey: 'bounded-idem-0', + teamName: 'team-a', + memberName: 'alice', + expectedFingerprint: createMemberSettingsFingerprint(current), + settings: { ...current.settings, role: 'role-1' }, + }; + + await feature.updateMemberSettings(firstRequest); + for (let index = 1; index <= 4_096; index += 1) { + const nextRole = `role-${index + 1}`; + await expect( + feature.updateMemberSettings({ + commandId: `bounded-command-${index}`, + idempotencyKey: `bounded-idem-${index}`, + teamName: 'team-a', + memberName: 'alice', + expectedFingerprint: createMemberSettingsFingerprint(current), + settings: { ...current.settings, role: nextRole }, + }) + ).resolves.toMatchObject({ outcome: 'completed', replayed: false }); + } + + expect(applyTarget).toHaveBeenCalledTimes(4_097); + await expect(feature.updateMemberSettings(firstRequest)).resolves.toMatchObject({ + outcome: 'target_conflict', + replayed: false, + }); + expect(applyTarget).toHaveBeenCalledTimes(4_097); + }); + + it('never evicts an in-flight fallback identity while admitting work at capacity', async () => { + let current = { ...snapshot('role-0'), teamIsAlive: false }; + let releasePending!: () => void; + const pendingGate = new Promise((resolve) => { + releasePending = resolve; + }); + const applyTarget = vi.fn(async (input) => { + if (input.settings.role === 'pending-role') await pendingGate; + current = { ...current, settings: input.settings }; + return { outcome: 'applied', snapshot: current, rollbackToken: null }; + }); + const feature = createTeamMemberSettingsFeature({ + repository: { + findTarget: async () => current, + classifyMissingTarget: async () => 'member_not_found', + applyTarget, + restoreTarget: async () => true, + }, + mutationSource: { runLiveRosterMutation: async (_team, operation) => operation() }, + lifecycleSource: { + attachLiveRosterMember: async () => undefined, + isTeamAlive: () => false, + }, + }); + + for (let index = 0; index < 4_095; index += 1) { + await feature.updateMemberSettings({ + commandId: `inflight-fill-command-${index}`, + idempotencyKey: `inflight-fill-idem-${index}`, + teamName: 'team-a', + memberName: 'alice', + expectedFingerprint: createMemberSettingsFingerprint(current), + settings: { ...current.settings, role: `fill-role-${index}` }, + }); + } + const pendingRequest: UpdateMemberSettingsRequest = { + commandId: 'inflight-command', + idempotencyKey: 'inflight-idem', + teamName: 'team-a', + memberName: 'alice', + expectedFingerprint: createMemberSettingsFingerprint(current), + settings: { ...current.settings, role: 'pending-role' }, + }; + const pending = feature.updateMemberSettings(pendingRequest); + await vi.waitFor(() => { + expect(applyTarget).toHaveBeenCalledTimes(4_096); + }); + + await expect( + feature.updateMemberSettings({ + commandId: 'overflow-command', + idempotencyKey: 'overflow-idem', + teamName: 'team-a', + memberName: 'alice', + expectedFingerprint: createMemberSettingsFingerprint(current), + settings: { ...current.settings, role: 'overflow-role' }, + }) + ).resolves.toMatchObject({ outcome: 'completed' }); + const duplicate = feature.updateMemberSettings(pendingRequest); + expect( + applyTarget.mock.calls.filter(([input]) => input.settings.role === 'pending-role') + ).toHaveLength(1); + + releasePending(); + await expect(pending).resolves.toMatchObject({ replayed: false }); + await expect(duplicate).resolves.toMatchObject({ replayed: true }); + expect( + applyTarget.mock.calls.filter(([input]) => input.settings.role === 'pending-role') + ).toHaveLength(1); + }); + + it('reconciles an unknown durable outcome as recovery required without another lifecycle effect', async () => { + const previous = snapshot('old'); + let current = previous; + const proposed = { ...previous, settings: { ...previous.settings, role: 'new' } }; + const attachLiveRosterMember = vi.fn(async () => undefined); + const commandRunner = { + run: vi.fn(async (input: { reconcile: () => Promise<{ result?: unknown }> }) => { + current = proposed; + const reconciliation = await input.reconcile(); + return { + outcome: ApplicationCommandRunOutcome.Reconciled, + result: reconciliation.result, + }; + }), + } as unknown as ApplicationCommandRunner; + const feature = createTeamMemberSettingsFeature({ + repository: { + findTarget: async () => current, + classifyMissingTarget: async () => 'member_not_found', + applyTarget: vi.fn(), + restoreTarget: async () => true, + }, + commandRunner, + mutationSource: { runLiveRosterMutation: async (_team, operation) => operation() }, + lifecycleSource: { attachLiveRosterMember, isTeamAlive: () => true }, + }); + + await expect( + feature.updateMemberSettings({ + commandId: 'command-unknown', + idempotencyKey: 'idem-unknown', + teamName: 'team-a', + memberName: 'alice', + expectedFingerprint: createMemberSettingsFingerprint(previous), + settings: proposed.settings, + }) + ).resolves.toMatchObject({ + outcome: 'completed', + effect: 'recovery_required', + replayed: false, + }); + expect(attachLiveRosterMember).not.toHaveBeenCalled(); + }); +}); diff --git a/test/main/features/team-provisioning/UpdateMemberSettingsUseCase.test.ts b/test/main/features/team-provisioning/UpdateMemberSettingsUseCase.test.ts new file mode 100644 index 0000000000..d0e782a70d --- /dev/null +++ b/test/main/features/team-provisioning/UpdateMemberSettingsUseCase.test.ts @@ -0,0 +1,371 @@ +import { + MemberSettingsMutationBusyError, + MemberSettingsPersistenceFailedError, +} from '@features/team-provisioning/core/application/ports/UpdateMemberSettingsPorts'; +import { UpdateMemberSettingsUseCase } from '@features/team-provisioning/core/application/use-cases/UpdateMemberSettingsUseCase'; +import { createMemberSettingsFingerprint } from '@features/team-provisioning/core/domain/memberSettingsPolicy'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EditableMemberSettings } from '@features/team-provisioning/contracts/memberSettings'; +import type { + MemberSettingsLifecyclePort, + MemberSettingsMutationGatePort, + MemberSettingsRepositoryPort, +} from '@features/team-provisioning/core/application/ports/UpdateMemberSettingsPorts'; +import type { MemberSettingsTargetSnapshot } from '@features/team-provisioning/core/domain/memberSettingsPolicy'; + +function settings(overrides: Partial = {}): EditableMemberSettings { + return { + role: 'Developer', + workflow: null, + isolation: null, + providerId: 'codex', + providerBackendId: null, + model: 'gpt-5.6', + effort: 'medium', + fastMode: null, + mcpPolicy: null, + ...overrides, + }; +} + +function target( + overrides: Partial = {} +): MemberSettingsTargetSnapshot { + return { + name: 'Worker', + agentType: 'general-purpose', + agentId: 'worker@team-a', + joinedAt: 123, + settings: settings(), + teamIsAlive: true, + leadProviderId: 'anthropic', + teamIsMixed: false, + runtimeLane: 'primary', + ...overrides, + }; +} + +function harness(current: MemberSettingsTargetSnapshot | null = target()) { + const events: string[] = []; + let stored = current; + const mutationGate: MemberSettingsMutationGatePort = { + async runExclusive(_teamName, operation) { + events.push('gate'); + return operation(); + }, + }; + const repository: MemberSettingsRepositoryPort = { + findTarget: vi.fn(async () => { + events.push('read-target'); + return stored; + }), + classifyMissingTarget: vi.fn(async () => 'member_not_found' as const), + applyTarget: vi.fn(async (input) => { + events.push('apply-target'); + if (!stored || createMemberSettingsFingerprint(stored) !== input.expectedFingerprint) { + return { outcome: 'target_conflict' as const, current: stored }; + } + stored = { ...stored, settings: input.settings }; + return { outcome: 'applied' as const, snapshot: stored, rollbackToken: current }; + }), + restoreTarget: vi.fn(async (input) => { + events.push('restore-target'); + if (!stored || createMemberSettingsFingerprint(stored) !== input.expectedFingerprint) { + return false; + } + stored = input.snapshot; + return true; + }), + }; + const lifecycle: MemberSettingsLifecyclePort = { + applyEffect: vi.fn(async (input) => { + events.push(`lifecycle:${input.action}`); + if (input.action === 'restart_opencode_lane') { + return 'opencode_lane_restart_started'; + } + if (input.action === 'require_team_relaunch') { + return 'team_relaunch_required'; + } + return 'member_restart_started'; + }), + restore: vi.fn(async () => { + events.push('restore-lifecycle'); + return true; + }), + }; + + return { + events, + mutationGate, + repository, + lifecycle, + useCase: new UpdateMemberSettingsUseCase({ mutationGate, repository, lifecycle }), + getStored: () => stored, + }; +} + +function request( + current: MemberSettingsTargetSnapshot, + nextSettings: EditableMemberSettings = settings({ role: 'Reviewer' }) +) { + return { + commandId: 'command-1', + idempotencyKey: 'update-member:team-a:worker:1', + teamName: 'team-a', + memberName: 'worker', + expectedFingerprint: createMemberSettingsFingerprint(current), + settings: nextSettings, + }; +} + +describe('UpdateMemberSettingsUseCase', () => { + it('returns no_changes without persistence or lifecycle work', async () => { + const current = target(); + const test = harness(current); + + await expect(test.useCase.execute(request(current, settings({ role: ' Developer ' })))).resolves + .toMatchObject({ outcome: 'completed', effect: 'no_changes' }); + expect(test.repository.applyTarget).not.toHaveBeenCalled(); + expect(test.lifecycle.applyEffect).not.toHaveBeenCalled(); + }); + + it('returns busy without reading or persisting when the team mutation lock is occupied', async () => { + const current = target(); + const test = harness(current); + vi.spyOn(test.mutationGate, 'runExclusive').mockRejectedValueOnce( + new MemberSettingsMutationBusyError('team-a') + ); + + await expect(test.useCase.execute(request(current))).resolves.toEqual({ + outcome: 'busy', + teamName: 'team-a', + memberName: 'worker', + replayed: false, + }); + expect(test.repository.findTarget).not.toHaveBeenCalled(); + }); + + it('reports a target conflict from the latest target read inside the gate', async () => { + const latest = target({ settings: settings({ role: 'Changed elsewhere' }) }); + const stale = target(); + const test = harness(latest); + + await expect(test.useCase.execute(request(stale))).resolves.toEqual({ + outcome: 'target_conflict', + memberName: 'Worker', + expectedFingerprint: createMemberSettingsFingerprint(stale), + actualFingerprint: createMemberSettingsFingerprint(latest), + reason: 'target_changed', + replayed: false, + }); + expect(test.events).toEqual(['gate', 'read-target']); + }); + + it.each(['member_not_found', 'team_not_found'] as const)( + 'returns the exact missing-target reason: %s', + async (reason) => { + const stale = target(); + const test = harness(null); + vi.mocked(test.repository.classifyMissingTarget).mockResolvedValueOnce(reason); + + await expect(test.useCase.execute(request(stale))).resolves.toMatchObject({ + outcome: 'target_conflict', + actualFingerprint: null, + reason, + replayed: false, + }); + } + ); + + it('delegates the final compare-and-apply conflict to the repository', async () => { + const current = target(); + const test = harness(current); + vi.mocked(test.repository.applyTarget).mockResolvedValueOnce({ + outcome: 'target_conflict', + current: target({ agentId: 'replacement@team-a' }), + }); + + await expect(test.useCase.execute(request(current))).resolves.toMatchObject({ + outcome: 'target_conflict', + actualFingerprint: createMemberSettingsFingerprint( + target({ agentId: 'replacement@team-a' }) + ), + }); + expect(test.lifecycle.applyEffect).not.toHaveBeenCalled(); + }); + + it('persists an offline target without invoking lifecycle effects', async () => { + const current = target({ teamIsAlive: false }); + const test = harness(current); + + await expect(test.useCase.execute(request(current))).resolves.toMatchObject({ + outcome: 'completed', + effect: 'persisted_only', + }); + expect(test.lifecycle.applyEffect).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'primary member', + target(), + settings({ role: 'Reviewer' }), + 'restart_member', + 'member_restart_started', + ], + [ + 'OpenCode secondary lane', + target({ + runtimeLane: 'opencode_secondary', + settings: settings({ providerId: 'opencode' }), + }), + settings({ role: 'Reviewer', providerId: 'opencode' }), + 'restart_opencode_lane', + 'opencode_lane_restart_started', + ], + ] as const)( + 'returns the factual lifecycle effect for %s', + async (_label, current, next, action, effect) => { + const test = harness(current); + + await expect(test.useCase.execute(request(current, next))).resolves.toMatchObject({ + outcome: 'completed', + effect, + }); + expect(test.lifecycle.applyEffect).toHaveBeenCalledWith( + expect.objectContaining({ teamName: 'team-a', action }) + ); + } + ); + + it.each([ + ['canonical lead', target({ agentType: 'team-lead' }), settings({ role: 'Reviewer' })], + [ + 'offline canonical lead', + target({ agentType: 'team-lead', teamIsAlive: false }), + settings({ role: 'Reviewer' }), + ], + [ + 'attempted legacy lead promotion', + target({ agentType: 'developer' }), + settings({ role: 'Team Lead' }), + ], + ['OpenCode-led team', target({ leadProviderId: 'opencode' }), settings({ role: 'Reviewer' })], + [ + 'primary-owned member in a mixed team', + target({ teamIsMixed: true }), + settings({ role: 'Reviewer' }), + ], + [ + 'primary to OpenCode ownership migration', + target(), + settings({ providerId: 'opencode' }), + ], + [ + 'OpenCode to primary ownership migration', + target({ + runtimeLane: 'opencode_secondary', + settings: settings({ providerId: 'opencode' }), + }), + settings({ providerId: 'codex' }), + ], + ] as const)('reports relaunch before persistence for %s', async (_label, current, next) => { + const test = harness(current); + + await expect(test.useCase.execute(request(current, next))).resolves.toMatchObject({ + outcome: 'completed', + effect: 'team_relaunch_required', + previousFingerprint: createMemberSettingsFingerprint(current), + currentFingerprint: createMemberSettingsFingerprint(current), + }); + expect(test.repository.applyTarget).not.toHaveBeenCalled(); + expect(test.lifecycle.applyEffect).not.toHaveBeenCalled(); + }); + + it('keeps sibling-safe semantics by passing only the exact target to repository mutation', async () => { + const current = target(); + const test = harness(current); + + await test.useCase.execute(request(current)); + + expect(test.repository.findTarget).toHaveBeenCalledWith('team-a', 'worker'); + expect(test.repository.applyTarget).toHaveBeenCalledWith( + expect.objectContaining({ + teamName: 'team-a', + memberName: 'worker', + expectedFingerprint: createMemberSettingsFingerprint(current), + }) + ); + expect(test.events.slice(0, 4)).toEqual([ + 'gate', + 'read-target', + 'apply-target', + 'lifecycle:restart_member', + ]); + }); + + it('rethrows the lifecycle failure after complete rollback', async () => { + const current = target(); + const test = harness(current); + vi.mocked(test.lifecycle.applyEffect).mockRejectedValueOnce(new Error('restart failed')); + + await expect(test.useCase.execute(request(current))).rejects.toThrow('restart failed'); + expect(test.getStored()).toEqual(current); + expect(test.events).toContain('restore-target'); + expect(test.events).toContain('restore-lifecycle'); + }); + + it('returns recovery_required when either rollback leg is incomplete', async () => { + const current = target(); + const test = harness(current); + vi.mocked(test.lifecycle.applyEffect).mockRejectedValueOnce(new Error('partial restart')); + vi.mocked(test.lifecycle.restore).mockResolvedValueOnce(false); + + await expect(test.useCase.execute(request(current))).resolves.toMatchObject({ + outcome: 'completed', + effect: 'recovery_required', + recovery: { + persistenceRestored: true, + lifecycleRestored: false, + cause: 'partial restart', + }, + }); + }); + + it('returns recovery_required without lifecycle work after incomplete persistence rollback', async () => { + const current = target(); + const test = harness(current); + vi.mocked(test.repository.applyTarget).mockRejectedValueOnce( + new MemberSettingsPersistenceFailedError('partial persistence rollback', true) + ); + + await expect(test.useCase.execute(request(current))).resolves.toMatchObject({ + outcome: 'completed', + effect: 'recovery_required', + recovery: { + persistenceRestored: false, + lifecycleRestored: true, + cause: 'partial persistence rollback', + }, + }); + expect(test.lifecycle.applyEffect).not.toHaveBeenCalled(); + }); + + it('does not restore the old runtime when persistence rollback is unproven', async () => { + const current = target(); + const test = harness(current); + vi.mocked(test.lifecycle.applyEffect).mockRejectedValueOnce(new Error('partial restart')); + vi.mocked(test.repository.restoreTarget).mockResolvedValueOnce(false); + + await expect(test.useCase.execute(request(current))).resolves.toMatchObject({ + outcome: 'completed', + effect: 'recovery_required', + recovery: { + persistenceRestored: false, + lifecycleRestored: false, + }, + }); + expect(test.lifecycle.restore).not.toHaveBeenCalled(); + }); +}); diff --git a/test/main/features/team-provisioning/memberSettingsPolicy.test.ts b/test/main/features/team-provisioning/memberSettingsPolicy.test.ts new file mode 100644 index 0000000000..844c99812f --- /dev/null +++ b/test/main/features/team-provisioning/memberSettingsPolicy.test.ts @@ -0,0 +1,218 @@ +import { + createMemberSettingsFingerprint, + isCanonicalLeadTarget, + type MemberSettingsTargetSnapshot, + normalizeEditableMemberSettings, + selectMemberSettingsLifecycleAction, +} from '@features/team-provisioning/core/domain/memberSettingsPolicy'; +import { describe, expect, it } from 'vitest'; + +import type { EditableMemberSettings } from '@features/team-provisioning/contracts/memberSettings'; + +function settings(overrides: Partial = {}): EditableMemberSettings { + return { + role: null, + workflow: null, + isolation: null, + providerId: null, + providerBackendId: null, + model: null, + effort: null, + fastMode: null, + mcpPolicy: null, + ...overrides, + }; +} + +function target( + overrides: Partial = {} +): MemberSettingsTargetSnapshot { + return { + name: 'Worker', + agentType: 'general-purpose', + agentId: 'worker@team-a', + joinedAt: 123, + settings: settings(), + teamIsAlive: true, + leadProviderId: 'anthropic', + teamIsMixed: false, + runtimeLane: 'primary', + ...overrides, + }; +} + +describe('member settings domain policy', () => { + it('normalizes every optional setting while preserving explicit null clears', () => { + expect( + normalizeEditableMemberSettings( + settings({ + role: ' Reviewer ', + workflow: ' ', + model: ' gpt-5.6 ', + mcpPolicy: { + mode: 'strictAllowlist', + scopes: { local: false, user: true }, + serverNames: [' zeta ', 'alpha', 'alpha', ''], + }, + }) + ) + ).toEqual( + settings({ + role: 'Reviewer', + workflow: null, + model: 'gpt-5.6', + mcpPolicy: { + mode: 'strictAllowlist', + scopes: { user: true, local: false }, + serverNames: ['alpha', 'zeta'], + }, + }) + ); + }); + + it('matches persisted MCP canonicalization for inheritance and empty restrictions', () => { + expect( + normalizeEditableMemberSettings( + settings({ mcpPolicy: { mode: 'inheritLead', scopes: {}, serverNames: [] } }) + ).mcpPolicy + ).toBeNull(); + expect( + normalizeEditableMemberSettings( + settings({ + mcpPolicy: { + mode: 'strictAllowlist', + scopes: { user: false, project: false, local: false }, + serverNames: [], + }, + }) + ).mcpPolicy + ).toEqual({ mode: 'appOnly' }); + expect( + createMemberSettingsFingerprint( + target({ settings: settings({ mcpPolicy: { mode: 'inheritLead' } }) }) + ) + ).toBe(createMemberSettingsFingerprint(target({ settings: settings({ mcpPolicy: null }) }))); + }); + + it('builds a stable target-only fingerprint from identity and canonical settings', () => { + const first = createMemberSettingsFingerprint( + target({ + teamIsAlive: true, + runtimeLane: 'primary', + settings: settings({ + role: ' Reviewer ', + mcpPolicy: { mode: 'strictAllowlist', serverNames: ['zeta', 'alpha'] }, + }), + }) + ); + const equivalentRuntimeSnapshot = createMemberSettingsFingerprint( + target({ + name: ' worker ', + teamIsAlive: false, + runtimeLane: 'opencode_secondary', + settings: settings({ + role: 'Reviewer', + mcpPolicy: { mode: 'strictAllowlist', serverNames: ['alpha', 'zeta'] }, + }), + }) + ); + + expect(equivalentRuntimeSnapshot).toBe(first); + const defaultFingerprint = createMemberSettingsFingerprint(target()); + expect(createMemberSettingsFingerprint(target({ joinedAt: '123' }))).toBe(defaultFingerprint); + expect(createMemberSettingsFingerprint(target({ joinedAt: ' 123.0 ' }))).toBe( + defaultFingerprint + ); + expect(createMemberSettingsFingerprint(target({ joinedAt: 999 }))).not.toBe(defaultFingerprint); + expect(createMemberSettingsFingerprint(target({ agentId: 'replacement@team-a' }))).not.toBe( + defaultFingerprint + ); + expect( + createMemberSettingsFingerprint(target({ settings: settings({ role: 'Reviewer' }) })) + ).not.toBe(defaultFingerprint); + }); + + it('detects only canonical lead identities and the exact normalized legacy role', () => { + expect(isCanonicalLeadTarget(target({ agentType: 'lead' }))).toBe(true); + expect(isCanonicalLeadTarget(target({ name: ' Team-Lead ', agentType: null }))).toBe(true); + expect( + isCanonicalLeadTarget( + target({ agentType: null, settings: settings({ role: ' Team Lead ' }) }) + ) + ).toBe(true); + expect( + isCanonicalLeadTarget( + target({ agentType: null, settings: settings({ role: 'tech team lead' }) }) + ) + ).toBe(false); + expect( + isCanonicalLeadTarget( + target({ agentType: 'developer', settings: settings({ role: 'Team Lead' }) }) + ) + ).toBe(false); + expect(isCanonicalLeadTarget(target({ agentType: 'team-lead-helper' }))).toBe(false); + expect( + isCanonicalLeadTarget(target({ agentType: null, settings: settings({ role: 'Lead Developer' }) })) + ).toBe(false); + }); + + it('selects persistence, member, OpenCode lane, and identity-safe relaunch actions', () => { + const offline = target({ teamIsAlive: false }); + expect(selectMemberSettingsLifecycleAction(offline, offline)).toBe('none'); + expect(selectMemberSettingsLifecycleAction(target(), target())).toBe('restart_member'); + const stableOpenCode = target({ + runtimeLane: 'opencode_secondary', + settings: settings({ providerId: 'opencode' }), + }); + expect( + selectMemberSettingsLifecycleAction(stableOpenCode, stableOpenCode) + ).toBe('restart_opencode_lane'); + const lead = target({ agentType: 'orchestrator' }); + expect(selectMemberSettingsLifecycleAction(lead, lead)).toBe('require_team_relaunch'); + const offlineLead = target({ agentType: 'orchestrator', teamIsAlive: false }); + expect(selectMemberSettingsLifecycleAction(offlineLead, offlineLead)).toBe( + 'require_team_relaunch' + ); + const proposedLegacyLead = target({ + agentType: null, + settings: settings({ role: 'Team Lead' }), + }); + expect(selectMemberSettingsLifecycleAction(target(), proposedLegacyLead)).toBe( + 'require_team_relaunch' + ); + const leadDeveloper = target({ + agentType: null, + settings: settings({ role: 'Lead Developer' }), + }); + expect(selectMemberSettingsLifecycleAction(leadDeveloper, leadDeveloper)).toBe( + 'restart_member' + ); + }); + + it('requires relaunch for ownership migrations in both directions', () => { + const primary = target({ settings: settings({ providerId: 'codex' }) }); + const openCode = target({ + runtimeLane: 'opencode_secondary', + settings: settings({ providerId: 'opencode' }), + }); + + expect(selectMemberSettingsLifecycleAction(primary, openCode)).toBe( + 'require_team_relaunch' + ); + expect(selectMemberSettingsLifecycleAction(openCode, primary)).toBe( + 'require_team_relaunch' + ); + }); + + it('requires relaunch for OpenCode-led teams and primary-owned members in mixed teams', () => { + const openCodeLed = target({ leadProviderId: 'opencode' }); + expect(selectMemberSettingsLifecycleAction(openCodeLed, openCodeLed)).toBe( + 'require_team_relaunch' + ); + + const mixedPrimary = target({ teamIsMixed: true, settings: settings({ providerId: 'codex' }) }); + expect(selectMemberSettingsLifecycleAction(mixedPrimary, mixedPrimary)).toBe( + 'require_team_relaunch' + ); + }); +}); diff --git a/test/main/features/team-provisioning/registerTeamMemberSettingsIpc.test.ts b/test/main/features/team-provisioning/registerTeamMemberSettingsIpc.test.ts new file mode 100644 index 0000000000..48a5541c3b --- /dev/null +++ b/test/main/features/team-provisioning/registerTeamMemberSettingsIpc.test.ts @@ -0,0 +1,118 @@ +import { TEAM_UPDATE_MEMBER_SETTINGS } from '@features/team-provisioning/contracts'; +import { + registerTeamMemberSettingsIpc, + removeTeamMemberSettingsIpc, +} from '@features/team-provisioning/main/adapters/input/registerTeamMemberSettingsIpc'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TeamMemberSettingsFeatureApi } from '@features/team-provisioning/main/composition/createTeamMemberSettingsFeature'; +import type { IpcMain } from 'electron'; + +function request() { + return { + commandId: ' command-1 ', + idempotencyKey: ' idem-1 ', + teamName: ' team-a ', + memberName: ' Alice ', + expectedFingerprint: ' fingerprint ', + settings: { + role: ' builder ', + workflow: null, + isolation: 'worktree', + providerId: 'codex', + providerBackendId: 'cli-sdk', + model: ' model ', + effort: 'high', + fastMode: 'on', + mcpPolicy: { + mode: 'strictAllowlist', + scopes: { user: true, project: false }, + serverNames: [' beta ', 'alpha', 'alpha'], + }, + }, + }; +} + +describe('registerTeamMemberSettingsIpc', () => { + it('validates and normalizes untrusted input into a standard success result', async () => { + let handler: ((event: unknown, value: unknown) => Promise) | undefined; + const ipcMain = { + handle: vi.fn((_channel, next) => { + handler = next; + }), + removeHandler: vi.fn(), + } as unknown as IpcMain; + const updateMemberSettings = vi.fn(async (value) => ({ + outcome: 'completed' as const, + effect: 'persisted_only' as const, + memberName: value.memberName, + previousFingerprint: value.expectedFingerprint, + currentFingerprint: 'next', + replayed: false, + })); + registerTeamMemberSettingsIpc(ipcMain, { updateMemberSettings }); + + const result = await handler?.({}, request()); + + expect(ipcMain.handle).toHaveBeenCalledWith(TEAM_UPDATE_MEMBER_SETTINGS, expect.any(Function)); + expect(updateMemberSettings).toHaveBeenCalledWith( + expect.objectContaining({ + commandId: 'command-1', + teamName: 'team-a', + memberName: 'Alice', + settings: expect.objectContaining({ + role: 'builder', + model: 'model', + mcpPolicy: expect.objectContaining({ serverNames: ['alpha', 'beta'] }), + }), + }) + ); + expect(result).toEqual({ + success: true, + data: expect.objectContaining({ outcome: 'completed' }), + }); + }); + + it.each([ + ['teamName', '../outside'], + ['memberName', '../outside'], + ] as const)('rejects path traversal in %s before calling the feature', async (field, value) => { + let handler: ((event: unknown, value: unknown) => Promise) | undefined; + const ipcMain = { + handle: vi.fn((_channel, next) => { + handler = next; + }), + removeHandler: vi.fn(), + } as unknown as IpcMain; + const updateMemberSettings = vi.fn(); + registerTeamMemberSettingsIpc(ipcMain, { updateMemberSettings }); + + const result = await handler?.({}, { ...request(), [field]: value }); + + expect(result).toMatchObject({ success: false }); + expect(updateMemberSettings).not.toHaveBeenCalled(); + }); + + it('rejects partial settings and removes the registered handler', async () => { + let handler: ((event: unknown, value: unknown) => Promise) | undefined; + const ipcMain = { + handle: vi.fn((_channel, next) => { + handler = next; + }), + removeHandler: vi.fn(), + } as unknown as IpcMain; + const feature: TeamMemberSettingsFeatureApi = { updateMemberSettings: vi.fn() }; + registerTeamMemberSettingsIpc(ipcMain, feature); + const invalid = request(); + delete (invalid.settings as Partial).workflow; + + await expect(handler?.({}, invalid)).resolves.toEqual({ + success: false, + error: 'settings.workflow is required; use null to clear it', + }); + expect(feature.updateMemberSettings).not.toHaveBeenCalled(); + + removeTeamMemberSettingsIpc(ipcMain); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(TEAM_UPDATE_MEMBER_SETTINGS); + }); +}); diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index faec2237b3..c4eea195d0 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -5612,6 +5612,41 @@ describe('ipc teams handlers', () => { vi.mocked(console.error).mockClear(); }); + it('does not treat a teammate role containing Lead as the team lead', async () => { + const handler = handlers.get(TEAM_REPLACE_MEMBERS)!; + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { + name: 'alice', + providerId: 'opencode', + role: 'Lead Developer', + currentTaskId: null, + taskCount: 0, + }, + { + name: 'team-lead', + providerId: 'codex', + role: 'Team Lead', + currentTaskId: null, + taskCount: 0, + }, + ], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const result = (await handler({} as never, 'my-team', { + members: [{ name: 'alice', role: 'Lead Developer', providerId: 'opencode' }], + })) as { success: boolean; error?: string }; + + expect(result.success).toBe(true); + expect(service.replaceMembers).toHaveBeenCalledTimes(1); + expect(teamHandlerMocks.attachLiveRosterMember).not.toHaveBeenCalled(); + }); + it('rolls back live OpenCode replaceMembers metadata when lifecycle attach fails', async () => { const handler = handlers.get(TEAM_REPLACE_MEMBERS)!; mockGetMembersMetaFile.mockResolvedValueOnce({ diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index ed15b8ba21..f51d095473 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1689,6 +1689,94 @@ describe('TeamDataService', () => { ); }); + it.each([ + { + label: 'config and metadata', + metaMembers: [ + { + name: 'alice', + role: 'Developer', + agentId: 'alice@old-runtime-team', + joinedAt: 1710000000000, + removedAt: 1715000000001, + }, + ], + }, + { label: 'config only', metaMembers: [] }, + ])( + 'clears $label restore tombstones without later provisioning resurrection', + async (testCase) => { + const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-restore-tombstone-')); + tempPaths.push(claudeRoot); + setClaudeBasePathOverride(claudeRoot); + const teamDir = path.join(claudeRoot, 'teams', 'runtime-team'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'runtime-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { + name: 'ALICE', + role: 'Developer', + agentId: 'alice@old-runtime-team', + joinedAt: 1710000000000, + removedAt: 1715000000000, + }, + ], + }) + ); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ version: 1, members: testCase.metaMembers }) + ); + + await expect( + new TeamDataService().restoreMember('runtime-team', 'alice') + ).resolves.toMatchObject({ + name: expect.stringMatching(/^alice$/i), + role: 'Developer', + agentId: undefined, + removedAt: undefined, + }); + + const persistedConfig = JSON.parse( + await fs.readFile(path.join(teamDir, 'config.json'), 'utf8') + ) as TeamConfig; + const persistedMeta = JSON.parse( + await fs.readFile(path.join(teamDir, 'members.meta.json'), 'utf8') + ) as { members: NonNullable }; + const restoredConfigMember = persistedConfig.members?.find( + (member) => member.name.toLowerCase() === 'alice' + ); + expect(restoredConfigMember).not.toHaveProperty('removedAt'); + expect(restoredConfigMember).not.toHaveProperty('agentId'); + expect( + persistedMeta.members.find((member) => member.name.toLowerCase() === 'alice') + ).not.toHaveProperty('removedAt'); + + const provisioningStore = ( + new TeamProvisioningService() as unknown as { + membersMetaStore: { + writeMembers( + teamName: string, + members: NonNullable + ): Promise; + }; + } + ).membersMetaStore; + await provisioningStore.writeMembers('runtime-team', persistedMeta.members); + + const afterProvisioningWrite = JSON.parse( + await fs.readFile(path.join(teamDir, 'members.meta.json'), 'utf8') + ) as { members: NonNullable }; + expect( + afterProvisioningWrite.members.find((member) => member.name.toLowerCase() === 'alice') + ).not.toHaveProperty('removedAt'); + } + ); + it('keeps getTeamData read-only and skips kanban garbage-collect', async () => { const order: string[] = []; const tasks: TeamTask[] = [ diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index fc5e6e4c49..69bc7bd5eb 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -350,6 +350,30 @@ describe('TeamMemberResolver', () => { expect(names).toContain('alice'); }); + it('keeps a member removed when either config or metadata has a case-insensitive tombstone', () => { + const resolver = new TeamMemberResolver(); + const config: TeamConfig = { + name: 'Team', + members: [ + { name: 'ALICE', agentType: 'general-purpose', removedAt: 1715000000000 }, + { name: 'BOB', agentType: 'general-purpose' }, + ], + }; + const metaMembers: TeamConfig['members'] = [ + { name: 'alice', agentType: 'general-purpose' }, + { name: 'bob', agentType: 'general-purpose', removedAt: 1715000000001 }, + ]; + + const members = resolver.resolveMembers(config, metaMembers, [], []); + + expect(members.find((member) => member.name.toLowerCase() === 'alice')?.removedAt).toBe( + 1715000000000 + ); + expect(members.find((member) => member.name.toLowerCase() === 'bob')?.removedAt).toBe( + 1715000000001 + ); + }); + it('sets currentTaskId for in_progress task', () => { const resolver = new TeamMemberResolver(); const config: TeamConfig = { diff --git a/test/renderer/components/team/dialogs/EditTeamDialog.test.ts b/test/renderer/components/team/dialogs/EditTeamDialog.test.ts index 807543cc9f..70ed4ded86 100644 --- a/test/renderer/components/team/dialogs/EditTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/EditTeamDialog.test.ts @@ -780,7 +780,7 @@ describe('EditTeamDialog', () => { }); }); - it('restarts an existing live teammate when role changes', async () => { + it('uses replaceMembers without a renderer restart when a live teammate role changes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any); vi.mocked(api.teams.replaceMembers).mockResolvedValue(undefined); @@ -834,7 +834,15 @@ describe('EditTeamDialog', () => { await Promise.resolve(); }); - expect(api.teams.restartMember).toHaveBeenCalledWith('live-team', 'alice'); + expect(api.teams.replaceMembers).toHaveBeenCalledWith( + 'live-team', + expect.objectContaining({ + members: expect.arrayContaining([ + expect.objectContaining({ name: 'alice', role: 'Developer' }), + ]), + }) + ); + expect(api.teams.restartMember).not.toHaveBeenCalled(); await act(async () => { root.unmount(); @@ -842,7 +850,7 @@ describe('EditTeamDialog', () => { }); }); - it('restarts an existing live teammate when MCP policy changes', async () => { + it('uses replaceMembers without a renderer restart when MCP policy changes', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any); vi.mocked(api.teams.replaceMembers).mockResolvedValue(undefined); @@ -897,7 +905,7 @@ describe('EditTeamDialog', () => { }), ], }); - expect(api.teams.restartMember).toHaveBeenCalledWith('live-team', 'alice'); + expect(api.teams.restartMember).not.toHaveBeenCalled(); await act(async () => { root.unmount(); @@ -1375,158 +1383,6 @@ describe('EditTeamDialog', () => { }); }); - it('allows retrying save after restart failures before props catch up to the committed state', async () => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any); - vi.mocked(api.teams.replaceMembers).mockResolvedValue(undefined); - vi.mocked(api.teams.restartMember) - .mockRejectedValueOnce(new Error('restart failed')) - .mockResolvedValueOnce(undefined); - - const host = document.createElement('div'); - document.body.appendChild(host); - const root = createRoot(host); - const onSaved = vi.fn(); - - const renderDialog = (role: string) => - React.createElement(EditTeamDialog, { - open: true, - teamName: 'live-team', - currentName: 'Current Team', - currentDescription: 'desc', - currentColor: 'blue', - currentMembers: [{ name: 'alice', role, providerId: 'codex', model: 'gpt-5.2' }] as any, - isTeamAlive: true, - projectPath: '/tmp/project', - onClose: vi.fn(), - onChangeLeadRuntime: vi.fn(), - onSaved, - }); - - await act(async () => { - root.render(renderDialog('Reviewer')); - await Promise.resolve(); - }); - - const saveButton = () => - Array.from(host.querySelectorAll('button')).find((button) => button.textContent === 'Save'); - - await act(async () => { - host - .querySelector('[data-testid="change-member-runtime"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); - - await act(async () => { - saveButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); - - expect(api.teams.restartMember).toHaveBeenCalledTimes(1); - - await act(async () => { - root.render( - React.createElement(EditTeamDialog, { - open: true, - teamName: 'live-team', - currentName: 'Current Team', - currentDescription: 'desc', - currentColor: 'blue', - currentMembers: [{ name: 'alice', role: 'Reviewer', providerId: 'codex', model: 'gpt-5.4' }] as any, - isTeamAlive: true, - projectPath: '/tmp/project', - onClose: vi.fn(), - onChangeLeadRuntime: vi.fn(), - onSaved, - }) - ); - await Promise.resolve(); - }); - - await act(async () => { - saveButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); - - expect(host.textContent).not.toContain( - 'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.' - ); - expect(api.teams.updateConfig).toHaveBeenCalledTimes(2); - expect(api.teams.restartMember).toHaveBeenCalledTimes(2); - - await act(async () => { - root.unmount(); - await Promise.resolve(); - }); - }); - - it('drops pending restart retry when the member runtime is changed away from the failed target', async () => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any); - vi.mocked(api.teams.replaceMembers).mockResolvedValue(undefined); - vi.mocked(api.teams.restartMember).mockRejectedValueOnce(new Error('restart failed')); - - const host = document.createElement('div'); - document.body.appendChild(host); - const root = createRoot(host); - - await act(async () => { - root.render( - React.createElement(EditTeamDialog, { - open: true, - teamName: 'live-team', - currentName: 'Current Team', - currentDescription: 'desc', - currentColor: 'blue', - currentMembers: [{ name: 'alice', role: 'Reviewer', providerId: 'codex', model: 'gpt-5.2' }] as any, - isTeamAlive: true, - projectPath: '/tmp/project', - onClose: vi.fn(), - onChangeLeadRuntime: vi.fn(), - onSaved: vi.fn(), - }) - ); - await Promise.resolve(); - }); - - const saveButton = () => - Array.from(host.querySelectorAll('button')).find((button) => button.textContent === 'Save'); - - await act(async () => { - host - .querySelector('[data-testid="change-member-runtime"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); - - await act(async () => { - saveButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); - - expect(api.teams.restartMember).toHaveBeenCalledTimes(1); - - await act(async () => { - host - .querySelector('[data-testid="revert-member-runtime"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); - - await act(async () => { - saveButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); - - expect(api.teams.restartMember).toHaveBeenCalledTimes(1); - - await act(async () => { - root.unmount(); - await Promise.resolve(); - }); - }); - it('shows an inline lead runtime action inside the lead context row', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index 9246e40f6d..4e11711d7c 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -147,6 +147,46 @@ const skippedSpawnEntry: MemberSpawnStatusEntry = { updatedAt: '2026-04-24T12:01:00.000Z', }; +describe('MemberCard member settings action', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('exposes settings for the team lead', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onEditMember = vi.fn(); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member: { + ...member, + name: 'lead', + agentType: 'team-lead', + role: 'Team Lead', + }, + memberColor: 'blue', + onEditMember, + }) + ); + await Promise.resolve(); + }); + + const settingsButton = host.querySelector('button[aria-label="Settings"]'); + expect(settingsButton).not.toBeNull(); + act(() => settingsButton?.click()); + expect(onEditMember).toHaveBeenCalledOnce(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); + describe('MemberCard starting-state visuals', () => { afterEach(() => { document.body.innerHTML = ''; diff --git a/test/renderer/components/team/members/MemberDetailHeader.test.ts b/test/renderer/components/team/members/MemberDetailHeader.test.ts index 157054ad62..50bd3d99a8 100644 --- a/test/renderer/components/team/members/MemberDetailHeader.test.ts +++ b/test/renderer/components/team/members/MemberDetailHeader.test.ts @@ -1,12 +1,20 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; + +import { MemberDetailHeader } from '@renderer/components/team/members/MemberDetailHeader'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ResolvedTeamMember } from '@shared/types'; vi.mock('@renderer/components/ui/badge', () => ({ - Badge: ({ children }: { children: React.ReactNode }) => - React.createElement('span', null, children), + Badge: ({ + children, + variant: _variant, + ...props + }: React.HTMLAttributes & { + children: React.ReactNode; + variant?: string; + }) => React.createElement('span', props, children), })); vi.mock('@renderer/components/ui/dialog', () => ({ @@ -16,12 +24,19 @@ vi.mock('@renderer/components/ui/dialog', () => ({ React.createElement('div', null, children), })); +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('span', null, children), +})); + vi.mock('@renderer/components/team/members/MemberRoleEditor', () => ({ MemberRoleEditor: () => null, })); -import { MemberDetailHeader } from '@renderer/components/team/members/MemberDetailHeader'; - const member: ResolvedTeamMember = { name: 'alice', status: 'unknown', @@ -42,6 +57,39 @@ describe('MemberDetailHeader spawn-aware presence', () => { vi.unstubAllGlobals(); }); + it('exposes the lead runtime edit action', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onEditMember = vi.fn(); + + await act(async () => { + root.render( + React.createElement(MemberDetailHeader, { + member: { + ...member, + name: 'lead', + agentType: 'team-lead', + role: 'Team Lead', + }, + onEditMember, + }) + ); + await Promise.resolve(); + }); + + const editButton = host.querySelector('button'); + expect(editButton).not.toBeNull(); + act(() => editButton?.click()); + expect(onEditMember).toHaveBeenCalledOnce(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows starting from spawn props even when coarse team state would read as idle', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); @@ -163,6 +211,8 @@ describe('MemberDetailHeader spawn-aware presence', () => { }); expect(host.textContent).toContain('Gemini quota retry'); + expect(host.textContent).toContain('Gemini cli backend error: capacity exceeded.'); + expect(host.querySelector('[title]')).toBeNull(); expect(host.textContent).not.toContain('idle'); await act(async () => { diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts index a976083a88..28ad43a9c2 100644 --- a/test/renderer/components/team/members/MemberList.test.ts +++ b/test/renderer/components/team/members/MemberList.test.ts @@ -25,6 +25,7 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ onRestartMember?: (memberName: string) => void; onSkipMemberForLaunch?: (memberName: string) => void; onRestoreMember?: (memberName: string) => void; + onEditMember?: () => void; isRemoved?: boolean; }) => { memberCardRenderSpy(props); @@ -39,6 +40,7 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ onRestartMember, onSkipMemberForLaunch, onRestoreMember, + onEditMember, isRemoved, } = props; return React.createElement( @@ -94,6 +96,13 @@ vi.mock('@renderer/components/team/members/MemberCard', () => ({ }, 'restore' ) + : null, + onEditMember + ? React.createElement( + 'button', + { 'data-testid': `edit-${member.name}`, type: 'button', onClick: onEditMember }, + 'edit' + ) : null ); }, @@ -160,9 +169,7 @@ function activeTask(id = 'task-active'): TeamTaskWithKanban { }; } -function liveRuntimeEntry( - overrides: Partial = {} -): TeamAgentRuntimeEntry { +function liveRuntimeEntry(overrides: Partial = {}): TeamAgentRuntimeEntry { return { memberName: 'bob', alive: true, @@ -1128,4 +1135,30 @@ describe('MemberList spawn-status memoization', () => { await Promise.resolve(); }); }); + + it('forwards targeted edit callbacks and invalidates the memo comparator when they change', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const firstEdit = vi.fn(); + const secondEdit = vi.fn(); + + await act(async () => { + root.render(React.createElement(MemberList, { members: [member], onEditMember: firstEdit })); + await Promise.resolve(); + }); + (host.querySelector('[data-testid="edit-bob"]') as HTMLButtonElement).click(); + expect(firstEdit).toHaveBeenCalledWith(member); + + await act(async () => { + root.render(React.createElement(MemberList, { members: [member], onEditMember: secondEdit })); + await Promise.resolve(); + }); + (host.querySelector('[data-testid="edit-bob"]') as HTMLButtonElement).click(); + expect(secondEdit).toHaveBeenCalledWith(member); + expect(firstEdit).toHaveBeenCalledTimes(1); + + await act(async () => root.unmount()); + }); }); diff --git a/test/renderer/constants/teamRoles.test.ts b/test/renderer/constants/teamRoles.test.ts new file mode 100644 index 0000000000..c2fae6cf82 --- /dev/null +++ b/test/renderer/constants/teamRoles.test.ts @@ -0,0 +1,9 @@ +import { isForbiddenTeamRole } from '@renderer/constants/teamRoles'; +import { describe, expect, it } from 'vitest'; + +describe('isForbiddenTeamRole', () => { + it('reserves canonical lead roles with normalized whitespace', () => { + expect(isForbiddenTeamRole(' Team Lead ')).toBe(true); + expect(isForbiddenTeamRole('Lead Developer')).toBe(false); + }); +}); diff --git a/test/shared/leadDetection.test.ts b/test/shared/leadDetection.test.ts new file mode 100644 index 0000000000..175820a7b4 --- /dev/null +++ b/test/shared/leadDetection.test.ts @@ -0,0 +1,11 @@ +import { isLeadMember } from '@shared/utils/leadDetection'; +import { describe, expect, it } from 'vitest'; + +describe('isLeadMember', () => { + it('supports the exact legacy team lead role without matching role substrings', () => { + expect(isLeadMember({ name: 'legacy', role: ' Team Lead ' })).toBe(true); + expect(isLeadMember({ name: 'worker', agentType: 'developer', role: 'Team Lead' })).toBe(false); + expect(isLeadMember({ name: 'worker', role: 'Lead Developer' })).toBe(false); + expect(isLeadMember({ name: 'worker', role: 'tech team lead' })).toBe(false); + }); +});