# Implementation Plan (v7 — Production-Ready Architecture) ## Обзор ~34 новых файлов + 18 модификаций + 18 тестов. Vertical slices (не backend-first). ### Изменения v6 → v7 (по результатам 3 deep-review тимлидов) | # | Баг/пробел в v6 | Исправление v7 | Severity | |---|-----------------|----------------|----------| | 35 | tasks/ — отдельная директория, watcher отсутствует (КРИТИЧНО) | ДВА watcher внутри FileWatcher: teamsWatcher + tasksWatcher | 9 | | 36 | `IPC_CHANNELS.TEAM_LIST` — объект не существует, реальный паттерн: flat `export const` | Все ссылки исправлены на `TEAM_LIST`, `TEAM_GET_DATA`, etc. (flat imports) | 7 | | 37 | `listTeams()` падает на dir без config.json (e.g. `default/`) | Graceful skip: `continue` при отсутствии config.json | 7 | | 38 | Tasks: throw при ENOENT (`~/.claude/tasks/{team}` может не существовать) | Graceful fallback: ENOENT → return `[]` | 7 | | 39 | `handleSendMessage`: `member as string` без валидации (path traversal) | Добавлен `validateMemberName()` guard | 8 | | 40 | `requestReview()` — stub без реализации sendMessage reviewer'у | Полная реализация: updateKanban + sendMessage | 6 | | 41 | Kanban: нет auto-review маппинга (completed → review) | Explicit маппинг: completed без kanban override → 'done' column | 5 | | 42 | `atomicWriteAsync`: `fs.existsSync()` в async функции | Заменён на `await fs.promises.mkdir(dir, { recursive: true })` | 4 | | 43 | Нет TeamsTab для list view (только TeamTab для individual teams) | Добавлен `TeamsTab` с `type: 'teams'` в discriminated union | 6 | | 44 | httpServer.broadcast для team-change не реализован | Добавлен в wireFileWatcherEvents | 4 | | 45 | Linux: fs.watch без recursive может пропускать events | Добавлен `recursive: true` (macOS native, Linux polyfill) | 3 | | 46 | Множественные file-change → множественные refreshes | Throttle: 300ms coalesce для team-change events в store | 3 | | 47 | Inbox `text` содержит serialized JSON (не plain text) | Документировано + UI отображает как текст (Claude Code сам парсит) | 2 | | 48 | handlers.ts: signature не принимает teamDataService | Добавлен параметр + wiring | 5 | ### Изменения v5 → v6 (по результатам 5 ревью-агентов + 4 тимлидов) | # | Баг/ошибка в v5 | Исправление v6 | Severity | |---|-----------------|----------------|----------| | 22 | `withInboxLock` cleanup: `.then()` creates new Promise, equality всегда false | Сохранять `myTurn` reference, сравнивать с ним | 7 | | 23 | Tab migration: 8 файлов → реально 12+ (пропущены TabBar, notificationSlice, contextStorage) | Полная карта миграции: 12 файлов | 6 | | 24 | BaseTab: `fromSearch`, `savedScrollTop`, `showContextPanel` — session-only, не shared | Перенести на SessionTab, оставить на BaseTab только shared поля | 6 | | 25 | `setupTeamChangeForwarding` — standalone функция сломается при SSH context switch | Интегрировать ВНУТРЬ `wireFileWatcherEvents()` | 8 | | 26 | TeamDataService в ServiceContext — НЕПРАВИЛЬНО (global, не per-workspace) | Global (как UpdaterService), передавать в initializeIpcHandlers | 8 | | 27 | TeamMemberResolver дублирует I/O (re-reads config+tasks) | Принимать pre-loaded data: `resolveMembers(config, tasks, messages)` | 5 | | 28 | kanban-state в `~/.claude/` root — namespace pollution | Хранить в `~/.claude/teams/{teamName}/kanban-state.json` | 5 | | 29 | GC на каждый fetch — лишние writes | Dirty-check: писать только если entries удалены | 4 | | 30 | `atomicWriteSync` в async методах KanbanManager | Использовать `atomicWriteAsync` | 5 | | 31 | Порядок: backend-first → 50% работы без видимого результата | Vertical slices: 5 итераций, каждая end-to-end | — | | 32 | TabInput: `Omit` с union → нужен distributive Omit | Explicit `SessionTabInput | TeamTabInput | ...` | 5 | | 33 | Missing httpServer.broadcast для team-change events | Добавить в wireFileWatcherEvents | 3 | | 34 | IPC handler channel strings hardcoded → должны быть из constants | Import из ipcChannels.ts | 3 | ### Изменения v4 → v5 (по результатам 4 deep-research агентов) | # | Вопрос v4 | Результат исследования | Решение v5 | |---|-----------|----------------------|------------| | 18 | Tab union — сколько мест ломается? | 12+ файлов, 30-35 строк | Полная карта миграции всех 12 файлов | | 19 | FileWatcher — риск 3-го watcher | ТРИВИАЛЬНЫЙ: copy-paste паттерн, ~60 LOC | Extend существующий FileWatcher (не отдельный) | | 20 | Inbox race condition | In-process mutex решает IPC races | withInboxLock с ПРАВИЛЬНЫМ cleanup | | 21 | End-to-end integration gaps | 12 точек интеграции полностью промаплены | Explicit checklist + exact file:line references | ### Изменения v3 → v4 (по результатам 5 ревью-агентов) | # | Проблема v3 | Исправление v4 | Severity | |---|-------------|----------------|----------| | 1 | `openSync('r')` — fsync не работает | `openSync('r+')` + mkdir recursive | 8 | | 2 | team:change event не прокинут | Полная wiring: FileWatcher → main → renderer → store | 9 | | 3 | unwrapIpcResult double wrapping | Убран второй unwrap, оставлен только try/catch | 7 | | 4 | Promise.all partial data loss | Promise.allSettled + graceful fallbacks | 7 | | 5 | TeamDataService без интерфейсов | 5 интерфейсов + Factory для DI/тестов | 7 | | 6 | Tab 'team' — optional fields | Discriminated union для Tab types | 5 | | 7 | teamRefreshGeneration memory leak | Cleanup при close tab + Map.delete | 6 | | 8 | setTimeout в store action | Заменён на `teamDeletedRedirect` flag в state | 3 | | 9 | kanban-state без atomic write | atomicWriteSync для всех write-path | 7 | | 10 | from: "user" не валидируется | validateFromField в guards.ts | 6 | | 11 | Sync ops блокируют event loop | Async версия atomicWrite для sendMessage | 5 | | 12 | Orphan .tmp cleanup отсутствует | cleanupOrphanTmpFiles() на startup | 6 | | 13 | Retry logic отсутствует | appendToInboxWithRetry + exponential backoff | 5 | | 14 | 1 тест vs 18-20 нужно | Полная тестовая стратегия: 18 файлов + fixtures | — | | 15 | Нет empty states | Empty states для всех компонентов | — | | 16 | ServiceContext не содержит team | ~~TeamDataService в ServiceContext~~ v6: global (баг #26) | 6 | | 17 | KanbanBoard props flow не описан | Явный props flow + callbacks | 8 | ### Архитектурные принципы (без изменений из v3) | Принцип | Что берём из проекта | Что улучшаем | |---------|---------------------|--------------| | **SRP** | Domain-driven services (analysis: 10 классов) | 5 backend классов вместо God-сервиса | | **OCP** | FileSystemProvider (2 реализации) | Интерфейсы для read/write операций | | **LSP** | Discriminated unions для chunks | Discriminated unions для Tab + MemberStatus | | **ISP** | ElectronAPI разбит на субинтерфейсы | TeamsAPI — отдельный субинтерфейс | | **DIP** | ServiceContext принимает deps через конструктор | Интерфейсы + Factory для всех 5 классов | ### Паттерны: consistency с проектом (без изменений из v3) | Решение | Что было в v2 | Что стало (v3/v4) | Почему | |---------|---------------|-------------------|--------| | **IPC handler** | Class `TeamIpcHandler` | `let state` + `getService()` guard | 12+ модулей: module-level | | **Renderer service** | Class `TeamService` | `unwrapIpc()` утилита | 15 slices вызывают api напрямую | | **Line limits** | ≤100 строк/класс | Без строгих лимитов, избегать 300-400+ | Прагматизм | ### IpcResult — дедупликация (без изменений из v3) Тип дублируется: `ConfigResult` в config.ts и `IpcResult` в preload/index.ts. Вынести в `@shared/types/ipc.ts` — единый источник правды. --- ## Порядок реализации ### Справочник шагов (Steps) ``` Phase 0: Подготовка 0.1 IpcResult дедупликация → @shared/types/ipc.ts Phase 1: Backend (Main Process) 1 Shared Types (team.ts) — discriminated unions 2 Path Helpers 3 Backend Services — 5 интерфейсов + 5 классов + Factory 4 atomicWrite.ts (ИСПРАВЛЕН: 'r+', mkdir, EXDEV, async, orphan cleanup) 5 TeamDataService — Facade (Promise.allSettled, не Promise.all) 6 IPC Channels 7 IPC Handlers — module-level + guard + wrapTeamHandler 8 Guards (validateTeamName, validateTaskId, validateFromField) 9 Preload Bridge + TeamsAPI 10 FileWatcher Extension + team:change wiring (v6: INSIDE wireFileWatcherEvents) 11 Global TeamDataService (v6: НЕ в ServiceContext) Phase 2: Frontend (Renderer) 12 unwrapIpc() (ИСПРАВЛЕН: без double wrapping) 13 Tab Type — Discriminated Union (НЕ optional fields) 14 teamSlice (ИСПРАВЛЕН: cleanup Map, без setTimeout, flag redirect) 15 Tab Integration (SortableTab, PaneContent, TabBar) 16 UI Components (14 шт) + Empty States 17 KanbanBoard с явным props flow 18 MessageComposer + inbox write (retry + delivery status) 19 ReviewDialog Phase 3: Testing 20 Test fixtures + mocks 21 Backend tests (8 файлов) 22 IPC tests (2 файла) 23 Renderer tests (4 файла) ``` ### v6: Порядок реализации — Vertical Slices > **v6 FIX (баг #31)**: Backend-first порядок означает, что 50% работы будет без видимого результата. > Переход к vertical slices: каждая итерация даёт видимый результат (types → backend → IPC → UI → тест). **Iteration 1: Core Foundation + Team List (Steps 0.1, 1, 2, 3-partial, 5-partial, 6, 7-partial, 9, 11, 12, 13, 14-partial, 15, 16-partial)** - Shared types, path helpers, IpcResult dedup - ConfigReader (только listTeams, v7: skip dirs without config.json) + Factory (partial) - IPC: team:list channel + handler + preload bridge (v7: flat export const) - Tab discriminated union (12 files migration) + TeamsTab (v7 #43) + TeamTab - teamSlice: fetchTeams only - TeamView + TeamListView + TeamEmptyState - **Результат**: открывается Teams tab, видно список команд (или empty state) **Iteration 2: Team Detail + Members (Steps 3-partial, 5-partial, 7-partial, 10, 14-partial, 16-partial)** - ConfigReader.getConfig + TaskReader (v7: ENOENT → []) + MemberResolver - TeamDataService.getTeamData (без kanban/inbox) - IPC: team:getData handler - FileWatcher: TWO watchers (teamsWatcher + tasksWatcher, v7 #35) + team:change wiring - teamSlice: selectTeam + refreshTeamData + throttle (v7 #46) - TeamDetailView + MemberList + MemberCard - **Результат**: клик на команду → видно участников и задачи **Iteration 3: Kanban Board (Steps 3-partial, 4, 5-partial, 7-partial, 8, 16-partial, 17)** - KanbanManager + atomicWrite (full) - Guards (validateTeamName, validateTaskId) - IPC: team:updateKanban handler - KanbanBoard + KanbanColumn + KanbanTaskCard + ReviewBadge - **Результат**: kanban доска с 5 колонками, click-to-move работает **Iteration 4: Messaging + Review (Steps 3-partial, 5-partial, 7-partial, 8, 14-partial, 16-partial, 18, 19)** - InboxReader + sendMessage + withInboxLock + retry - IPC: team:sendMessage + team:requestReview handlers + validateMemberName (v7 #39) - requestReview: updateKanban + sendMessage to reviewer (v7 #40) - teamSlice: sendTeamMessage + moveTaskToColumn - ActivityTimeline + MessageComposer + ReviewDialog - **Результат**: можно отправлять сообщения, запрашивать ревью **Iteration 5: Testing + Polish (Steps 20-23)** - Test fixtures + mocks - Backend tests (8 файлов), IPC tests (2), Renderer tests (4) - Empty states для всех panels - Error handling polish, loading states - **Результат**: полное покрытие тестами, production-ready --- ## Phase 0: Подготовка ### Step 0.1: IpcResult дедупликация (без изменений) **Create** `src/shared/types/ipc.ts` ```typescript export interface IpcResult { success: boolean; data?: T; error?: string; } ``` **Modify** `src/shared/types/index.ts` — `export type { IpcResult } from './ipc';` **Modify** `src/main/ipc/config.ts` — удалить `ConfigResult`, импортировать `IpcResult` из `@shared/types` **Modify** `src/preload/index.ts` — удалить `IpcResult`, импортировать из `@shared/types` --- ## Phase 1: Backend (Main Process) ### Step 1: Shared Types **Create** `src/shared/types/team.ts` ```typescript // === Типы с диска (Claude Code format) === export interface TeamConfig { name: string; description: string; createdAt: number; leadAgentId: string; leadSessionId: string; members: TeamMember[]; } export interface TeamMember { name: string; agentId: string; agentType: string; model?: string; joinedAt?: number; tmuxPaneId?: string; cwd?: string; subscriptions?: string[]; } export interface InboxMessage { from: string; /** * v7 NOTE (#47): `text` field contains SERIALIZED JSON (not plain text). * Claude Code serializes message content as JSON string. * UI should display as plain text — Claude Code itself handles parsing. * Example: '{"type":"message","content":"Hello","summary":"Greeting"}' */ text: string; summary?: string; timestamp: string; color?: string; read: boolean; /** v7 NOTE: old messages may lack messageId — field is optional */ messageId?: string; } export interface TeamTask { id: string; subject: string; description?: string; activeForm?: string; owner?: string; // ОПЦИОНАЛЕН status: 'pending' | 'in_progress' | 'completed' | 'deleted'; blocks: string[]; blockedBy: string[]; metadata?: Record; } // === Наши типы === export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; export interface TeamSummary { name: string; description: string; memberCount: number; taskCount: number; lastActivity: string | null; } export interface TeamData { config: TeamConfig; members: ResolvedTeamMember[]; tasks: TeamTask[]; messages: InboxMessage[]; kanbanState: KanbanState; /** Partial load warnings (e.g., "messages failed to load") */ warnings?: string[]; } export interface ResolvedTeamMember { name: string; agentId?: string; agentType?: string; color?: string; currentTask?: TeamTask; messageCount: number; lastActive?: string; status: MemberStatus; role: 'worker' | 'reviewer'; } // === Kanban === export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; export interface KanbanColumn { id: KanbanColumnId; label: string; } export type ReviewAction = 'approve' | 'request_changes'; export interface KanbanTaskState { column: KanbanColumnId; reviewAction?: ReviewAction; reviewer?: string; comment?: string; movedAt: string; } export interface KanbanState { teamName: string; reviewers: string[]; tasks: Record; } export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [ { id: 'todo', label: 'TODO' }, { id: 'in_progress', label: 'IN PROGRESS' }, { id: 'done', label: 'DONE' }, { id: 'review', label: 'REVIEW' }, { id: 'approved', label: 'APPROVED' }, ]; // === Events === export interface TeamChangeEvent { type: 'config' | 'task' | 'inbox'; teamName: string; detail?: string; } // === Message delivery === export interface SendMessageResult { delivered: boolean; messageId: string; } ``` **Modify** `src/shared/types/index.ts` — add `export * from './team';` ### Step 2: Path Helpers (без изменений) **Modify** `src/main/utils/pathDecoder.ts` ```typescript export function getTeamsBasePath(): string { return path.join(getClaudeBasePath(), 'teams'); } export function getTasksBasePath(): string { return path.join(getClaudeBasePath(), 'tasks'); } ``` ### Step 3: Backend Services — 5 интерфейсов + 5 классов + Factory **NEW в v4**: Интерфейсы для DI и тестирования. ``` src/main/services/team/ ├── interfaces.ts — 5 интерфейсов (ITeamConfigReader, etc.) ├── TeamConfigReader.ts — implements ITeamConfigReader ├── TeamTaskReader.ts — implements ITeamTaskReader ├── TeamInboxReader.ts — implements ITeamInboxReader ├── TeamMemberResolver.ts — implements ITeamMemberResolver ├── TeamKanbanManager.ts — implements ITeamKanbanManager ├── TeamDataService.ts — Facade, принимает интерфейсы ├── TeamDataServiceFactory.ts — Composition root ├── atomicWrite.ts — Atomic write utils (sync + async) └── index.ts — barrel export ``` #### interfaces.ts (NEW в v4) ```typescript import type { InboxMessage, KanbanState, KanbanTaskState, ResolvedTeamMember, SendMessageResult, TeamConfig, TeamSummary, TeamTask, } from '@shared/types'; export interface ITeamConfigReader { listTeams(): Promise; getConfig(teamName: string): Promise; } export interface ITeamTaskReader { getTasks(teamName: string): Promise; } export interface ITeamInboxReader { getInboxNames(teamName: string): Promise; getMessages(teamName: string): Promise; getMessagesFor(teamName: string, member: string): Promise; sendMessage( teamName: string, member: string, msg: { from: string; text: string; summary?: string } ): Promise; } export interface ITeamMemberResolver { /** v6 FIX: принимает pre-loaded data, не дублирует I/O */ resolveMembers( config: TeamConfig, tasks: TeamTask[], messages: InboxMessage[] ): ResolvedTeamMember[]; } export interface ITeamKanbanManager { getState(teamName: string): Promise; updateTaskState(teamName: string, taskId: string, state: Partial): Promise; removeTaskState(teamName: string, taskId: string): Promise; garbageCollect(teamName: string, existingTaskIds: Set): Promise; } ``` #### TeamConfigReader ```typescript export class TeamConfigReader implements ITeamConfigReader { constructor(private readonly teamsBasePath: string) {} async listTeams(): Promise { const teamsDir = this.teamsBasePath; let entries: string[]; try { entries = await fs.promises.readdir(teamsDir); } catch { return []; // ~/.claude/teams/ doesn't exist yet } const summaries: TeamSummary[] = []; for (const name of entries) { const configPath = path.join(teamsDir, name, 'config.json'); try { const raw = await fs.promises.readFile(configPath, 'utf8'); const config: TeamConfig = JSON.parse(raw); summaries.push({ name: config.name, description: config.description ?? '', memberCount: config.members?.length ?? 0, taskCount: 0, // populated later if needed lastActivity: null, }); } catch { // v7 FIX (#37): skip dirs without config.json (e.g. "default/" has only inboxes/) logger.debug(`Skipping team dir without valid config: ${name}`); continue; } } return summaries; } async getConfig(teamName: string): Promise { const configPath = path.join(this.teamsBasePath, teamName, 'config.json'); try { const raw = await fs.promises.readFile(configPath, 'utf8'); return JSON.parse(raw); } catch { return null; } } } ``` #### TeamTaskReader ```typescript export class TeamTaskReader implements ITeamTaskReader { constructor(private readonly tasksBasePath: string) {} async getTasks(teamName: string): Promise { const tasksDir = path.join(this.tasksBasePath, teamName); let entries: string[]; try { entries = await fs.promises.readdir(tasksDir); } catch (error) { // v7 FIX (#38): ~/.claude/tasks/{team}/ may not exist (graceful fallback) if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return []; } throw error; } const tasks: TeamTask[] = []; for (const file of entries) { if (!file.endsWith('.json') || file.startsWith('.') || file === '.lock' || file === '.highwatermark') continue; try { const raw = await fs.promises.readFile(path.join(tasksDir, file), 'utf8'); const task: TeamTask = JSON.parse(raw); if (task.status !== 'deleted') { tasks.push(task); } } catch { logger.debug(`Failed to parse task file: ${file}`); } } return tasks; } } ``` #### TeamInboxReader (ИСПРАВЛЕН: async sendMessage) ```typescript export class TeamInboxReader implements ITeamInboxReader { constructor(private readonly teamsBasePath: string) {} async getInboxNames(teamName: string): Promise { /* readdir inboxes/ */ } async getMessages(teamName: string): Promise { /* merge all, sort by timestamp */ } async getMessagesFor(teamName: string, member: string): Promise { /* one member */ } /** * Пишет в MAIN inbox. Async версия (не блокирует event loop). * Использует atomic write + messageId verify + retry. */ async sendMessage( teamName: string, member: string, msg: { from: string; text: string; summary?: string } ): Promise { const inboxPath = path.join(this.teamsBasePath, teamName, 'inboxes', `${member}.json`); const messageId = await appendToInboxWithRetry(inboxPath, { from: msg.from, text: msg.text, summary: msg.summary, timestamp: new Date().toISOString(), }); return { delivered: true, messageId }; } } ``` #### TeamMemberResolver (v6 FIX: принимает pre-loaded data) ```typescript export class TeamMemberResolver implements ITeamMemberResolver { /** * v6 FIX: принимает pre-loaded data вместо re-reading через readers. * TeamDataService уже загрузил config, tasks, messages — не дублируем I/O. * Стал СИНХРОННЫМ (pure transform, без async fs reads). */ resolveMembers( config: TeamConfig, tasks: TeamTask[], messages: InboxMessage[] ): ResolvedTeamMember[] { // union(config.members + message senders + task owners) // deduplicate by name (case-insensitive trim) // extract colors from messages // determine status via determineMemberStatus() // match currentTask by owner field from tasks } private determineMemberStatus( lastMessageTime: Date | null, hasActiveTask: boolean ): MemberStatus { if (!lastMessageTime) return 'unknown'; const ageMs = Date.now() - lastMessageTime.getTime(); const ACTIVE_WINDOW = 5 * 60 * 1000; // 5 min const IDLE_WINDOW = 60 * 60 * 1000; // 1 hour if (ageMs < ACTIVE_WINDOW || hasActiveTask) return 'active'; if (ageMs < IDLE_WINDOW) return 'idle'; return 'terminated'; } } ``` #### TeamKanbanManager (v6 FIX: path + async + GC dirty-check) ```typescript export class TeamKanbanManager implements ITeamKanbanManager { /** v6: принимает teamsBasePath (не configDir) для хранения внутри team dir */ constructor(private readonly teamsBasePath: string) {} async getState(teamName: string): Promise { // read kanban-state.json, return default if missing } async updateTaskState(teamName: string, taskId: string, state: Partial): Promise { const current = await this.getState(teamName); current.tasks[taskId] = { ...current.tasks[taskId], ...state, movedAt: new Date().toISOString(), }; // v6 FIX: atomicWriteAsync вместо atomicWriteSync (async method не должен блокировать event loop) await atomicWriteAsync(this.getStatePath(teamName), JSON.stringify(current, null, 2)); } async removeTaskState(teamName: string, taskId: string): Promise { const current = await this.getState(teamName); delete current.tasks[taskId]; // v6 FIX: atomicWriteAsync await atomicWriteAsync(this.getStatePath(teamName), JSON.stringify(current, null, 2)); } async garbageCollect(teamName: string, existingTaskIds: Set): Promise { const current = await this.getState(teamName); const toRemove = Object.keys(current.tasks).filter(id => !existingTaskIds.has(id)); // v6 FIX: dirty-check — write ONLY if entries actually removed if (toRemove.length === 0) return; for (const id of toRemove) { delete current.tasks[id]; } await atomicWriteAsync(this.getStatePath(teamName), JSON.stringify(current, null, 2)); } private getStatePath(teamName: string): string { // v6 FIX: хранить внутри team directory, не в ~/.claude/ root return path.join(this.teamsBasePath, teamName, 'kanban-state.json'); } } ``` ### Step 4: atomicWrite.ts (ПОЛНОСТЬЮ ПЕРЕПИСАН в v4) ```typescript import * as fs from 'fs'; import * as path from 'path'; import { randomUUID } from 'crypto'; import { createLogger } from '@shared/utils/logger'; const logger = createLogger('util:atomicWrite'); /** * Atomic write (SYNC): tmp + fsync + rename. * * v4 исправления: * - openSync('r+') вместо 'r' для корректного fsync * - mkdir recursive перед write (первый write в новую team) * - EXDEV handling (cross-mount rename fallback) */ export function atomicWriteSync(targetPath: string, data: string): void { const dir = path.dirname(targetPath); const tmpPath = path.join(dir, `.tmp.${randomUUID()}`); try { // Ensure parent directory exists (first write to new team) if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(tmpPath, data, 'utf8'); // fsync с ПРАВИЛЬНЫМ флагом (v3 bug: 'r' → v4 fix: 'r+') try { const fd = fs.openSync(tmpPath, 'r+'); fs.fsyncSync(fd); fs.closeSync(fd); } catch { // fsync best effort — продолжаем } // rename с EXDEV fallback try { fs.renameSync(tmpPath, targetPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EXDEV') { fs.copyFileSync(tmpPath, targetPath); try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } } else { throw error; } } } catch (error) { try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } throw error; } } /** * Atomic write (ASYNC): не блокирует event loop. * Используется для sendMessage (inbox write может быть 50-100ms). */ export async function atomicWriteAsync(targetPath: string, data: string): Promise { const dir = path.dirname(targetPath); const tmpPath = path.join(dir, `.tmp.${randomUUID()}`); try { // v7 FIX (#42): no fs.existsSync in async function — mkdir recursive is idempotent await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.writeFile(tmpPath, data, 'utf8'); try { const fd = await fs.promises.open(tmpPath, 'r+'); await fd.sync(); await fd.close(); } catch { // fsync best effort } try { await fs.promises.rename(tmpPath, targetPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EXDEV') { await fs.promises.copyFile(tmpPath, targetPath); try { await fs.promises.unlink(tmpPath); } catch { /* ignore */ } } else { throw error; } } } catch (error) { try { await fs.promises.unlink(tmpPath); } catch { /* ignore */ } throw error; } } /** * v6: In-process write queue — serializes concurrent IPC writes to same inbox. * Eliminates read-modify-write race condition within the Electron main process. * * Pattern source: FileWatcher.processingInProgress Set (same codebase). * Note: does NOT protect against cross-process races (CLI writes). * Cross-process safety via verify + retry (below). * * v6 FIX: v5 had a bug where `.then()` created a new Promise on each call, * so the equality check `=== existing.then(() => next)` was ALWAYS false. * Fixed by saving `myTurn` reference and comparing against it. * Also made generic to avoid closure tricks for return values. */ const inboxWriteLocks = new Map>(); export async function withInboxLock( inboxPath: string, fn: () => Promise ): Promise { // Wait for predecessor (or resolve immediately if no queue) const predecessor = inboxWriteLocks.get(inboxPath) ?? Promise.resolve(); // Create our "done" signal let release!: () => void; const myTurn = new Promise(r => { release = r; }); // Register ourselves as the current tail of the queue inboxWriteLocks.set(inboxPath, myTurn); // Wait for predecessor to finish await predecessor; try { return await fn(); } finally { release(); // Cleanup Map only if we're still the last in queue // v6 FIX: compare against saved `myTurn` reference (not `.then()` which creates new Promise) if (inboxWriteLocks.get(inboxPath) === myTurn) { inboxWriteLocks.delete(inboxPath); } } } /** * Append message to inbox JSON array with retry + verify. * v4: async, exponential backoff, до 3 retry. * v5: wrapped in withInboxLock to serialize concurrent writes. */ export async function appendToInboxWithRetry( inboxPath: string, message: Record, maxRetries: number = 3 ): Promise { let lastError: Error | null = null; let resultId = ''; await withInboxLock(inboxPath, async () => { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { resultId = await appendToInboxWithVerify(inboxPath, message); return; } catch (error) { lastError = error as Error; logger.warn(`Inbox write attempt ${attempt + 1} failed: ${lastError.message}`); if (attempt < maxRetries) { const delayMs = 10 * Math.pow(2, attempt); // 10ms, 20ms, 40ms await new Promise(resolve => setTimeout(resolve, delayMs)); } } } throw lastError ?? new Error('Failed to append to inbox after retries'); }); return resultId; } /** * Single attempt: read → append → atomic write → verify. */ async function appendToInboxWithVerify( inboxPath: string, message: Record ): Promise { const messageId = randomUUID(); const fullMessage = { ...message, messageId, read: false }; // 1. Read existing let existing: unknown[] = []; try { const raw = await fs.promises.readFile(inboxPath, 'utf8'); existing = JSON.parse(raw); if (!Array.isArray(existing)) existing = []; } catch { // Start fresh if missing/broken } // 2. Append const updated = [...existing, fullMessage]; // 3. Atomic write (async — не блокирует event loop) await atomicWriteAsync(inboxPath, JSON.stringify(updated, null, 2)); // 4. Verify — detect race condition const written = JSON.parse( await fs.promises.readFile(inboxPath, 'utf8') ) as Array<{ messageId?: string }>; const found = written.some(m => m.messageId === messageId); if (!found) { throw new Error(`Message ${messageId} lost (race condition detected)`); } return messageId; } /** * Cleanup orphan .tmp files on startup. * Called once in main/index.ts after TeamDataService creation. */ export async function cleanupOrphanTmpFiles(basePaths: string[]): Promise { const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours const now = Date.now(); for (const basePath of basePaths) { try { if (!fs.existsSync(basePath)) continue; const entries = await fs.promises.readdir(basePath, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { // Scan subdirectories (inboxes/, etc.) const subPath = path.join(basePath, entry.name); const subEntries = await fs.promises.readdir(subPath).catch(() => []); for (const file of subEntries) { if (typeof file === 'string' && file.startsWith('.tmp.')) { const filePath = path.join(subPath, file); try { const stat = await fs.promises.stat(filePath); if (now - stat.mtimeMs > MAX_AGE_MS) { await fs.promises.unlink(filePath); logger.debug(`Cleaned orphan: ${filePath}`); } } catch { /* ignore */ } } } } else if (entry.name.startsWith('.tmp.')) { const filePath = path.join(basePath, entry.name); try { const stat = await fs.promises.stat(filePath); if (now - stat.mtimeMs > MAX_AGE_MS) { await fs.promises.unlink(filePath); } } catch { /* ignore */ } } } } catch (error) { logger.warn(`Orphan cleanup failed for ${basePath}:`, error); } } } ``` ### Step 5: TeamDataService — Facade (ИСПРАВЛЕН: интерфейсы + Promise.allSettled) ```typescript import type { ITeamConfigReader, ITeamTaskReader, ITeamInboxReader, ITeamMemberResolver, ITeamKanbanManager, } from './interfaces'; import type { TeamData, TeamSummary, SendMessageResult, KanbanTaskState } from '@shared/types'; import { createLogger } from '@shared/utils/logger'; const logger = createLogger('Service:TeamData'); /** * Facade: оркестрирует 5 reader-классов. * v4: принимает ИНТЕРФЕЙСЫ (не конкретные классы) для DI/тестов. * v4: Promise.allSettled для graceful degradation. */ export class TeamDataService { constructor( private readonly configReader: ITeamConfigReader, private readonly taskReader: ITeamTaskReader, private readonly inboxReader: ITeamInboxReader, private readonly memberResolver: ITeamMemberResolver, private readonly kanbanManager: ITeamKanbanManager, ) {} async listTeams(): Promise { return this.configReader.listTeams(); } async getTeamData(teamName: string): Promise { // 1. Config is required — fail fast if missing const config = await this.configReader.getConfig(teamName); if (!config) { throw new Error(`Team not found: ${teamName}`); } // 2. Load remaining data with partial failure tolerance const [tasksResult, messagesResult, kanbanResult] = await Promise.allSettled([ this.taskReader.getTasks(teamName), this.inboxReader.getMessages(teamName), this.kanbanManager.getState(teamName), ]); const warnings: string[] = []; // v7 FIX (#38): Tasks — graceful degradation (tasks dir may not exist) let tasks: TeamTask[] = []; if (tasksResult.status === 'rejected') { logger.warn(`Failed to load tasks for ${teamName}:`, tasksResult.reason); warnings.push('Tasks failed to load'); } else { tasks = tasksResult.value; } // Messages: graceful degradation → empty array let messages: InboxMessage[] = []; if (messagesResult.status === 'rejected') { logger.warn(`Failed to load messages for ${teamName}:`, messagesResult.reason); warnings.push('Messages failed to load'); } else { messages = messagesResult.value; } // Kanban: graceful degradation → default state let kanbanState: KanbanState; if (kanbanResult.status === 'rejected') { logger.warn(`Failed to load kanban state for ${teamName}:`, kanbanResult.reason); kanbanState = { teamName, reviewers: [], tasks: {} }; warnings.push('Kanban state failed to load'); } else { kanbanState = kanbanResult.value; } // 3. GC kanban state AFTER loading tasks const existingTaskIds = new Set(tasks.map(t => t.id)); await this.kanbanManager.garbageCollect(teamName, existingTaskIds); // 4. Resolve members — v6 FIX: pass pre-loaded data (не дублируем I/O) const members = this.memberResolver.resolveMembers(config, tasks, messages); return { config, members, tasks, messages, kanbanState, warnings }; } async sendMessage( teamName: string, member: string, msg: { from: string; text: string; summary?: string } ): Promise { return this.inboxReader.sendMessage(teamName, member, msg); } async updateKanban( teamName: string, taskId: string, state: Partial ): Promise { await this.kanbanManager.updateTaskState(teamName, taskId, state); } /** v7 FIX (#40): полная реализация — kanban move + notify reviewer via inbox */ async requestReview(teamName: string, taskId: string, reviewer?: string): Promise { // 1. Move task to 'review' column in kanban await this.kanbanManager.updateTaskState(teamName, taskId, { column: 'review', reviewer, }); // 2. If reviewer specified, send inbox message to notify them if (reviewer) { const task = (await this.taskReader.getTasks(teamName)).find(t => t.id === taskId); const subject = task?.subject ?? `Task #${taskId}`; await this.inboxReader.sendMessage(teamName, reviewer, { from: 'user', text: JSON.stringify({ type: 'review_request', taskId, subject, message: `Please review: ${subject}`, }), summary: `Review requested: ${subject}`, }); } } } ``` #### TeamDataServiceFactory.ts (NEW в v4) ```typescript import { TeamConfigReader } from './TeamConfigReader'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamMemberResolver } from './TeamMemberResolver'; import { TeamKanbanManager } from './TeamKanbanManager'; import { TeamDataService } from './TeamDataService'; /** * Composition root: создаёт TeamDataService с конкретными реализациями. * В тестах: можно создать TeamDataService с mock-реализациями интерфейсов. * * v6 FIX: MemberResolver больше не принимает readers (pure transform). * v6 FIX: KanbanManager принимает teamsBasePath (хранит state в team dir). */ export function createTeamDataService( teamsBasePath: string, tasksBasePath: string ): TeamDataService { const configReader = new TeamConfigReader(teamsBasePath); const taskReader = new TeamTaskReader(tasksBasePath); const inboxReader = new TeamInboxReader(teamsBasePath); const memberResolver = new TeamMemberResolver(); const kanbanManager = new TeamKanbanManager(teamsBasePath); return new TeamDataService(configReader, taskReader, inboxReader, memberResolver, kanbanManager); } ``` ### Step 6: IPC Channels (v7 FIX: flat export const) > **v7 FIX (#36)**: Проект использует flat `export const`, НЕ object namespace. > Паттерн: `export const CONFIG_GET = 'config:get'`, а не `IPC_CHANNELS.CONFIG_GET`. **Modify** `src/preload/constants/ipcChannels.ts` — добавить в конец файла: ```typescript // ============================================================================= // Team API Channels // ============================================================================= /** List all teams */ export const TEAM_LIST = 'team:list'; /** Get full team data */ export const TEAM_GET_DATA = 'team:getData'; /** Send message to team member */ export const TEAM_SEND_MESSAGE = 'team:sendMessage'; /** Update kanban task state */ export const TEAM_UPDATE_KANBAN = 'team:updateKanban'; /** Request review for a task */ export const TEAM_REQUEST_REVIEW = 'team:requestReview'; /** Team change event channel (main -> renderer) */ export const TEAM_CHANGE = 'team:change'; ``` ### Step 7: IPC Handlers (v7 FIX: flat imports + validateMemberName) **Create** `src/main/ipc/teams.ts` ```typescript import type { IpcMain, IpcMainInvokeEvent } from 'electron'; import type { TeamDataService } from '@main/services/team'; import type { IpcResult } from '@shared/types'; // v7 FIX (#36): flat imports — project uses `export const`, NOT namespace object import { TEAM_LIST, TEAM_GET_DATA, TEAM_SEND_MESSAGE, TEAM_UPDATE_KANBAN, TEAM_REQUEST_REVIEW, } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; const logger = createLogger('IPC:team'); // Module-level state + guard (consistency с 12+ модулями) interface TeamHandlerState { service: TeamDataService; initialized: boolean; } const state: TeamHandlerState = { service: null as unknown as TeamDataService, initialized: false, }; function getService(): TeamDataService { if (!state.initialized) throw new Error('Team handlers not initialized'); return state.service; } export function initializeTeamHandlers(service: TeamDataService): void { if (state.initialized) { logger.warn('Team handlers already initialized'); return; } state.service = service; state.initialized = true; } export function registerTeamHandlers(ipcMain: IpcMain): void { // v7 FIX (#36): flat channel constants ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_GET_DATA, handleGetData); ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban); ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview); logger.info('Team handlers registered'); } export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_GET_DATA); ipcMain.removeHandler(TEAM_SEND_MESSAGE); ipcMain.removeHandler(TEAM_UPDATE_KANBAN); ipcMain.removeHandler(TEAM_REQUEST_REVIEW); } /** * v4: Helper для consistent error handling. * Все handlers используют одинаковый pattern. */ async function wrapTeamHandler( operation: string, handler: () => Promise ): Promise> { try { const result = await handler(); return { success: true, data: result }; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error(`[team:${operation}] Error:`, error); return { success: false, error: message }; } } async function handleListTeams(): Promise> { return wrapTeamHandler('list', () => getService().listTeams()); } async function handleGetData( _event: IpcMainInvokeEvent, teamName: unknown ): Promise> { const validation = validateTeamName(teamName); if (!validation.valid) return { success: false, error: validation.error! }; return wrapTeamHandler('getData', () => getService().getTeamData(validation.value)); } async function handleSendMessage( _event: IpcMainInvokeEvent, teamName: unknown, member: unknown, text: unknown, summary: unknown ): Promise> { const teamValidation = validateTeamName(teamName); if (!teamValidation.valid) return { success: false, error: teamValidation.error! }; // v7 FIX (#39): validate member name to prevent path traversal const memberValidation = validateMemberName(member); if (!memberValidation.valid) return { success: false, error: memberValidation.error! }; if (typeof text !== 'string' || text.trim().length === 0) { return { success: false, error: 'text must be a non-empty string' }; } return wrapTeamHandler('sendMessage', () => getService().sendMessage(teamValidation.value, memberValidation.value, { from: 'user', text: text as string, summary: typeof summary === 'string' ? summary : undefined, }) ); } // handleUpdateKanban, handleRequestReview — аналогично через wrapTeamHandler ``` ### Step 8: Guards (v7: + validateMemberName для path traversal prevention) **Modify** `src/main/ipc/guards.ts` ```typescript const TEAM_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; const TASK_ID_PATTERN = /^[0-9]{1,10}$/; // v7 FIX (#39): member names are used in file paths (inboxes/{member}.json) // Must prevent path traversal (e.g., "../../etc/passwd") const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; export function validateTeamName(value: unknown): ValidationResult { if (typeof value !== 'string') return { valid: false, error: 'teamName must be a string' }; const trimmed = value.trim(); if (!TEAM_NAME_PATTERN.test(trimmed)) { return { valid: false, error: `Invalid team name: ${trimmed}` }; } return { valid: true, value: trimmed }; } export function validateTaskId(value: unknown): ValidationResult { if (typeof value !== 'string') return { valid: false, error: 'taskId must be a string' }; if (!TASK_ID_PATTERN.test(value)) { return { valid: false, error: `Invalid task ID: ${value}` }; } return { valid: true, value }; } /** * v7 FIX (#39): Validates member name used in inbox file paths. * Critical for security: member is interpolated into file path: * path.join(teamsBasePath, teamName, 'inboxes', `${member}.json`) * Without validation, attacker could send member="../../etc/passwd" for path traversal. */ export function validateMemberName(value: unknown): ValidationResult { if (typeof value !== 'string') return { valid: false, error: 'member must be a string' }; const trimmed = value.trim(); if (!MEMBER_NAME_PATTERN.test(trimmed)) { return { valid: false, error: `Invalid member name: ${trimmed}` }; } return { valid: true, value: trimmed }; } ``` ### Step 9: Preload Bridge (без изменений) **Modify** `src/shared/types/api.ts` — add `TeamsAPI` + extend `ElectronAPI` **Modify** `src/preload/index.ts` — teams bridge implementation ### Step 10: FileWatcher Extension + team:change Wiring (v7: ДВА watcher'а) **Modify** `src/main/services/infrastructure/FileWatcher.ts` > **v5 research result**: FileWatcher уже имеет 2 параллельных watcher (projectsWatcher + todosWatcher). > Copy-paste паттерн. ~60 LOC per watcher, нулевой риск для существующих watchers. > > **v7 CRITICAL FIX (#35)**: `~/.claude/tasks/` — ОТДЕЛЬНАЯ директория от `~/.claude/teams/`. > Нужны ДВА новых watcher'а: > - `teamsWatcher` для `~/.claude/teams/` (config changes, inbox changes) > - `tasksWatcher` для `~/.claude/tasks/` (task file changes) > Оба emit'ят `'team-change'` event с разным `type`. Добавить: - `teamsWatcher: fs.FSWatcher | null = null` property - `tasksWatcher: fs.FSWatcher | null = null` property - `startTeamsWatcher()` method (копия todosWatcher pattern) - `startTasksWatcher()` method (копия todosWatcher pattern) - `handleTeamsChange()` → emit `'team-change'` с `type: 'config' | 'inbox'` - `handleTasksChange()` → emit `'team-change'` с `type: 'task'` - v7 (#45): `{ recursive: true }` option для fs.watch (macOS native support, Linux Node 19+) - Update `stop()` и `dispose()` для cleanup обоих watcher'ов - SSH polling: автоматически поддержан (бесплатно) ```typescript // Property declarations (alongside existing projectsWatcher, todosWatcher): private teamsWatcher: fs.FSWatcher | null = null; private tasksWatcher: fs.FSWatcher | null = null; // Start methods (called from start() alongside existing watchers): private startTeamsWatcher(): void { const teamsPath = getTeamsBasePath(); try { // v7 (#45): recursive:true — macOS native, Linux Node 19+ this.teamsWatcher = fs.watch(teamsPath, { recursive: true }, (eventType, filename) => { this.handleTeamsChange(eventType, filename); }); this.teamsWatcher.on('error', (err) => { logger.warn('Teams watcher error, scheduling retry:', err.message); this.teamsWatcher = null; setTimeout(() => this.startTeamsWatcher(), WATCHER_RETRY_MS); }); } catch { logger.debug('Teams dir not available, will retry'); setTimeout(() => this.startTeamsWatcher(), WATCHER_RETRY_MS); } } private startTasksWatcher(): void { const tasksPath = getTasksBasePath(); try { this.tasksWatcher = fs.watch(tasksPath, { recursive: true }, (eventType, filename) => { this.handleTasksChange(eventType, filename); }); this.tasksWatcher.on('error', (err) => { logger.warn('Tasks watcher error, scheduling retry:', err.message); this.tasksWatcher = null; setTimeout(() => this.startTasksWatcher(), WATCHER_RETRY_MS); }); } catch { logger.debug('Tasks dir not available, will retry'); setTimeout(() => this.startTasksWatcher(), WATCHER_RETRY_MS); } } // Change handlers with debounce (reuse existing debounce pattern): private handleTeamsChange(eventType: string, filename: string | null): void { // Debounce + determine team name from filename path // filename = "my-team/config.json" or "my-team/inboxes/member.json" const teamName = filename?.split(path.sep)[0] ?? 'unknown'; const isInbox = filename?.includes('inboxes'); this.emit('team-change', { type: isInbox ? 'inbox' : 'config', teamName, detail: filename ?? undefined, } satisfies TeamChangeEvent); } private handleTasksChange(eventType: string, filename: string | null): void { const teamName = filename?.split(path.sep)[0] ?? 'unknown'; this.emit('team-change', { type: 'task', teamName, detail: filename ?? undefined, } satisfies TeamChangeEvent); } // Cleanup (in stop() and dispose()): if (this.teamsWatcher) { this.teamsWatcher.close(); this.teamsWatcher = null; } if (this.tasksWatcher) { this.tasksWatcher.close(); this.tasksWatcher = null; } ``` Total: ~120 LOC в FileWatcher (60 per watcher) + 20 LOC wiring в main/index.ts **Modify** `src/main/index.ts` — **v6 FIX: wiring ВНУТРЬ `wireFileWatcherEvents()`** (баг #25) > **v6 FIX**: Standalone `setupTeamChangeForwarding()` сломается при SSH context switch, > потому что `wireFileWatcherEvents()` перезапускается для нового context, а standalone — нет. > Интеграция внутрь `wireFileWatcherEvents()` гарантирует переподключение при смене context. ```typescript // В wireFileWatcherEvents() (src/main/index.ts, ~line 105): // ДОБАВИТЬ рядом с существующими file-change и todo-change handlers: function wireFileWatcherEvents(fileWatcher: FileWatcher, win: BrowserWindow): () => void { // ... existing file-change handler ... // ... existing todo-change handler ... // v6: team-change forwarding (ВНУТРИ wireFileWatcherEvents, не standalone) // v7 FIX (#36): flat import, не IPC_CHANNELS object const teamChangeHandler = (event: TeamChangeEvent) => { if (win && !win.isDestroyed()) { win.webContents.send(TEAM_CHANGE, event); } // v7 FIX (#44): broadcast to HTTP sidecar (browser mode support) httpServer?.broadcast('team-change', event); }; fileWatcher.on('team-change', teamChangeHandler); // Return combined cleanup (existing + team) return () => { // ... existing cleanup ... fileWatcher.off('team-change', teamChangeHandler); }; } ``` ### Step 11: Global TeamDataService (v6 FIX: НЕ в ServiceContext) > **v6 FIX (баг #26)**: ServiceContext — per-workspace (создаётся заново при SSH context switch). > TeamDataService читает `~/.claude/teams/` и `~/.claude/tasks/` — ЛОКАЛЬНЫЕ пути, > не зависящие от workspace/SSH. Аналогично UpdaterService — создаётся один раз глобально. **Modify** `src/main/index.ts` — создать TeamDataService глобально: ```typescript import { createTeamDataService } from '@main/services/team'; import { cleanupOrphanTmpFiles } from '@main/services/team/atomicWrite'; import { getTeamsBasePath, getTasksBasePath } from '@main/utils/pathDecoder'; // Global — не зависит от ServiceContext/workspace const teamDataService = createTeamDataService( getTeamsBasePath(), getTasksBasePath() ); // Orphan cleanup on startup cleanupOrphanTmpFiles([getTeamsBasePath(), getTasksBasePath()]); ``` **Modify** `src/main/ipc/handlers.ts` — v7 FIX (#48): добавить teamDataService как параметр: ```typescript import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams'; import type { TeamDataService } from '@main/services/team'; // v7 FIX (#48): signature расширен — teamDataService передаётся напрямую (global, не из context) // Existing params сохранены, добавлен последний параметр export function initializeIpcHandlers( registry: ServiceContextRegistry, updater: UpdaterService, sshManager: SshConnectionManager, contextCallbacks: { /* existing */ }, teamDataService: TeamDataService // v7: global parameter, НЕ из ServiceContext ): void { // ... existing initialize calls (projects, sessions, search, etc.) ... // Team handlers — global service initializeTeamHandlers(teamDataService); // ... existing register calls ... registerTeamHandlers(ipcMain); logger.info('All handlers registered'); } export function removeIpcHandlers(): void { // ... existing remove calls ... removeTeamHandlers(ipcMain); logger.info('All handlers removed'); } ``` **НЕ модифицируем** `ServiceContext.ts` — TeamDataService туда НЕ добавляется. --- ## Phase 2: Frontend (Renderer) ### Step 12: unwrapIpc() (ИСПРАВЛЕН: без double wrapping) **Create** `src/renderer/utils/unwrapIpc.ts` ```typescript import { createLogger } from '@shared/utils/logger'; const logger = createLogger('api:unwrap'); export class IpcError extends Error { constructor( public operation: string, message: string, public originalError?: unknown ) { super(message); this.name = 'IpcError'; } } /** * Единая обёртка для IPC вызовов. * * v4 FIX: invokeIpcWithResult() в preload УЖЕ throws на !success, * поэтому НЕ нужен второй unwrap. Просто catch + wrap в IpcError. * * Использование: * const teams = await unwrapIpc('team:list', () => api.teams.list()); */ export async function unwrapIpc( operation: string, fn: () => Promise ): Promise { try { return await fn(); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error(`[IPC:${operation}] Failed: ${message}`); throw new IpcError(operation, message, error); } } // v4: unwrapIpcResult УДАЛЁН — preload уже делает unwrap. // Если handler возвращает IpcResult, preload/invokeIpcWithResult // автоматически проверяет success и throws Error на failure. // unwrapIpc достаточно для всех случаев. ``` ### Step 13: Tab Type — Discriminated Union (v6: полная карта миграции 12 файлов) > **v6 FIX**: v5 насчитал 8 файлов. Реально 12+ файлов, 30-35 строк. > Пропущены: TabBar.tsx (6+ unsafe accesses), notificationSlice.ts, contextStorage.ts. > BaseTab поля исправлены: `fromSearch`, `savedScrollTop`, `showContextPanel` → session-only. > TabInput тип требует distributive variant. **Полная карта миграции (12 файлов):** | Файл | Строк | Статус | |------|-------|--------| | `store/slices/tabSlice.ts` | 8 | 6 с guards, 2 в factory. TabInput нужен distributive | | `store/slices/sessionDetailSlice.ts` | 3 | все с guards | | `store/slices/contextSlice.ts` | 4 | все с guards | | `store/slices/notificationSlice.ts` | 3 | **ПРОПУЩЕН в v5**: creates SessionTab via openTab, findTabBySessionAndProject | | `store/slices/paneSlice.ts` | 1 | с guard | | `store/index.ts` | 2 | 1 unsafe (projectId access) | | `components/layout/PaneContent.tsx` | 4 | все с type switch, добавить 'team' case | | `components/layout/SortableTab.tsx` | 3 | 2 с guards, 1 TAB_ICONS → добавить 'team' | | `components/layout/SessionTabContent.tsx` | 2 | UNSAFE: нет type check | | `components/layout/TabBar.tsx` | 6 | **ПРОПУЩЕН в v5**: activeTab.projectId, contextMenuTab?.sessionId (6+ accesses) | | `services/contextStorage.ts` | 2 | **ПРОПУЩЕН в v5**: ContextSnapshot.openTabs: Tab[] → version migration | | Test files | ~5 | Тесты конструирующие Tab objects | **Unsafe-места (нужен type narrowing):** 1. `SessionTabContent.tsx:65` — добавить `if (isSessionTab(tab))` 2. `store/index.ts:259` — `visibleSessionTab?.projectId` → narrow first 3. `TabBar.tsx:213` — `activeTab.projectId && activeTab.sessionId` → narrow 4. `TabBar.tsx:239-244` — `contextMenuTab?.sessionId` (4 accesses) → narrow 5. `notificationSlice.ts` — openTab input shape → SessionTabInput 6. `contextStorage.ts` — IndexedDB snapshot version bump (old Tab shape → new union) **Modify** `src/renderer/types/tabs.ts` ```typescript // v6: Discriminated union. Session-only поля на SessionTab, НЕ на BaseTab. // 12 файлов миграции. TabInput — distributive variant. interface BaseTab { id: string; label: string; createdAt: number; // Shared UI fields (genuinely used by ALL tab types): pendingNavigation?: TabNavigationRequest; lastConsumedNavigationId?: string; } export interface SessionTab extends BaseTab { type: 'session'; sessionId: string; projectId: string; // v6 FIX: session-only поля (НЕ на BaseTab): fromSearch?: boolean; savedScrollTop?: number; showContextPanel?: boolean; } /** v7 FIX (#43): List view — singleton tab для списка всех команд */ export interface TeamsTab extends BaseTab { type: 'teams'; } /** Individual team detail tab */ export interface TeamTab extends BaseTab { type: 'team'; teamName: string; } export interface DashboardTab extends BaseTab { type: 'dashboard'; } export interface NotificationsTab extends BaseTab { type: 'notifications'; } export interface SettingsTab extends BaseTab { type: 'settings'; } export type Tab = SessionTab | TeamsTab | TeamTab | DashboardTab | NotificationsTab | SettingsTab; // Type guards export function isSessionTab(tab: Tab): tab is SessionTab { return tab.type === 'session'; } export function isTeamsTab(tab: Tab): tab is TeamsTab { return tab.type === 'teams'; } export function isTeamTab(tab: Tab): tab is TeamTab { return tab.type === 'team'; } // v6 FIX: TabInput — distributive variant (Omit не дистрибутивен в TypeScript) export type SessionTabInput = Omit; export type TeamsTabInput = Omit; export type TeamTabInput = Omit; export type DashboardTabInput = Omit; export type NotificationsTabInput = Omit; export type SettingsTabInput = Omit; export type TabInput = SessionTabInput | TeamsTabInput | TeamTabInput | DashboardTabInput | NotificationsTabInput | SettingsTabInput; ``` **NOTE**: Breaking change для 12 файлов. Все `tab.sessionId` → type narrowing: `if (isSessionTab(tab)) { tab.sessionId }`. **NOTE**: `contextStorage.ts` — bump SNAPSHOT_VERSION, handle deserialization of old Tab shape. ### Step 14: teamSlice (ИСПРАВЛЕН: cleanup, без setTimeout, delivery status) **Create** `src/renderer/store/slices/teamSlice.ts` ```typescript import { unwrapIpc, IpcError } from '@renderer/utils/unwrapIpc'; import type { TeamData, TeamSummary, KanbanTaskState, SendMessageResult } from '@shared/types'; const { api } = window.electronAPI; // Generation pattern из sessionSlice const teamRefreshGeneration = new Map(); export interface TeamSlice { // State teams: TeamSummary[]; teamsLoading: boolean; teamsError: string | null; selectedTeamName: string | null; selectedTeamData: TeamData | null; selectedTeamLoading: boolean; selectedTeamError: string | null; /** v4: flag for component-level redirect (вместо setTimeout) */ teamDeletedRedirect: boolean; /** v4: message delivery state */ sendingMessage: boolean; lastSendResult: SendMessageResult | null; sendError: string | null; // Actions fetchTeams: () => Promise; selectTeam: (teamName: string) => Promise; refreshTeamData: (teamName: string) => Promise; sendTeamMessage: (member: string, text: string, summary?: string) => Promise; moveTaskToColumn: (taskId: string, state: Partial) => Promise; openTeamTab: (teamName: string) => void; openTeamsListTab: () => void; /** v4: cleanup generation map при закрытии tab */ cleanupTeamState: (teamName: string) => void; clearTeamDeletedRedirect: () => void; } export const createTeamSlice: StateCreator = (set, get) => ({ teams: [], teamsLoading: false, teamsError: null, selectedTeamName: null, selectedTeamData: null, selectedTeamLoading: false, selectedTeamError: null, teamDeletedRedirect: false, sendingMessage: false, lastSendResult: null, sendError: null, fetchTeams: async () => { set({ teamsLoading: true, teamsError: null }); try { const teams = await unwrapIpc('team:list', () => api.teams.list()); set({ teams, teamsLoading: false }); } catch (error) { set({ teamsError: error instanceof IpcError ? error.message : String(error), teamsLoading: false, }); } }, selectTeam: async (teamName: string) => { set({ selectedTeamName: teamName, selectedTeamLoading: true, selectedTeamError: null, teamDeletedRedirect: false, }); try { const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); set({ selectedTeamData: data, selectedTeamLoading: false }); } catch (error) { if (error instanceof IpcError && error.message.includes('not found')) { // v4: set flag, компонент обработает redirect в useEffect set({ selectedTeamData: null, selectedTeamError: 'Team was deleted', selectedTeamLoading: false, teamDeletedRedirect: true, }); return; } set({ selectedTeamError: String(error), selectedTeamLoading: false }); } }, refreshTeamData: async (teamName: string) => { const generation = (teamRefreshGeneration.get(teamName) ?? 0) + 1; teamRefreshGeneration.set(teamName, generation); try { const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); if (teamRefreshGeneration.get(teamName) !== generation) return; set({ selectedTeamData: data, selectedTeamLoading: false }); } catch (error) { if (teamRefreshGeneration.get(teamName) === generation) { set({ selectedTeamError: String(error), selectedTeamLoading: false }); } } }, sendTeamMessage: async (member: string, text: string, summary?: string) => { const teamName = get().selectedTeamName; if (!teamName) return; set({ sendingMessage: true, sendError: null, lastSendResult: null }); try { const result = await unwrapIpc('team:sendMessage', () => api.teams.sendMessage(teamName, member, text, summary) ); set({ sendingMessage: false, lastSendResult: result }); // Refresh data to show new message get().refreshTeamData(teamName); } catch (error) { set({ sendingMessage: false, sendError: error instanceof IpcError ? error.message : String(error), }); } }, // v4: cleanup при закрытии team tab cleanupTeamState: (teamName: string) => { teamRefreshGeneration.delete(teamName); }, clearTeamDeletedRedirect: () => { set({ teamDeletedRedirect: false }); }, // ... moveTaskToColumn, openTeamTab, openTeamsListTab }); ``` **Modify** `src/renderer/store/types.ts` — `& TeamSlice` **Modify** `src/renderer/store/index.ts` — compose + team-change listener: ```typescript // В initializeNotificationListeners(): // v7 FIX (#46): throttle/coalesce — multiple rapid file changes → single refresh let teamRefreshTimer: ReturnType | null = null; const TEAM_REFRESH_THROTTLE_MS = 300; api.teams.onTeamChange((event: TeamChangeEvent) => { const state = useStore.getState(); if (state.selectedTeamName !== event.teamName) return; // Coalesce rapid changes (e.g., multiple task files written in 100ms) if (teamRefreshTimer) clearTimeout(teamRefreshTimer); teamRefreshTimer = setTimeout(() => { teamRefreshTimer = null; const currentState = useStore.getState(); if (currentState.selectedTeamName === event.teamName) { currentState.refreshTeamData(event.teamName); } }, TEAM_REFRESH_THROTTLE_MS); }); // Also refresh teams list on any team-change (for TeamListView updates) api.teams.onTeamChange(() => { // Simpler: just refetch teams list (lightweight operation) useStore.getState().fetchTeams(); }); ``` ### Step 15: Tab Integration (v7: + TeamsTab) - `SortableTab.tsx` — `teams: Users, team: Users` in TAB_ICONS (v7: два типа) - `PaneContent.tsx`: ```typescript // v7 FIX (#43): separate TeamsTab (list) vs TeamTab (detail) {isTeamsTab(tab) && } {isTeamTab(tab) && } ``` TeamView без teamName → renders TeamListView; с teamName → TeamDetailView - `TabBar.tsx` — Teams button: `openTeamsListTab()` (singleton) - При close team tab → вызвать `cleanupTeamState(tab.teamName)` - TeamsTab — singleton: при повторном клике не создаёт новый tab, а переключает на существующий ### Step 16: UI Components + Empty States ``` src/renderer/components/team/ ├── TeamView.tsx — Router: list vs detail ├── TeamListView.tsx — Grid of team cards ├── TeamDetailView.tsx — Members (left) + Kanban (center) + Activity (right) ├── TeamEmptyState.tsx — No teams (icon + message) ├── TeamDetailLoadingState.tsx — Skeleton for all 3 panels (NEW v4) ├── members/ │ ├── MemberList.tsx — Left panel (240px) │ ├── MemberCard.tsx — Color dot, name, status, current task │ └── MemberListEmpty.tsx — "No members" (NEW v4) ├── kanban/ │ ├── KanbanBoard.tsx — 5 columns, click-to-move │ ├── KanbanColumn.tsx — Header + count + cards │ ├── KanbanTaskCard.tsx — Owner badge, subject, column selector, blocked indicator │ ├── ReviewBadge.tsx — Approve/RequestChanges badge │ └── KanbanEmpty.tsx — "No tasks" (NEW v4) ├── activity/ │ ├── ActivityTimeline.tsx — Right panel (320px) │ ├── ActivityItem.tsx — Color dot, sender, time, summary │ ├── MessageComposer.tsx — Recipient + textarea + send + delivery status │ └── ActivityEmpty.tsx — "No messages" (NEW v4) └── dialogs/ └── ReviewDialog.tsx — Approve / Request Changes с комментарием ``` ### Step 17: KanbanBoard с явным props flow (NEW в v4) ```typescript // Данные текут: teamSlice.selectedTeamData → TeamDetailView → KanbanBoard interface KanbanBoardProps { tasks: TeamTask[]; kanbanState: KanbanState; onMoveTask: (taskId: string, column: KanbanColumnId) => Promise; onRequestReview: (taskId: string) => void; } // TeamDetailView пробрасывает: moveTaskToColumn(taskId, { column })} onRequestReview={(taskId) => setReviewDialogTaskId(taskId)} /> // KanbanBoard группирует tasks по columns: // 1. tasks с kanbanState → используем kanbanState.column // 2. tasks БЕЗ kanbanState → v7 FIX (#41) маппинг по task.status: // pending → todo, in_progress → in_progress, completed → done // // v7 NOTE: auto-review is Phase 2. MVP maps completed → 'done'. // User can manually move tasks to 'review' column via click-to-move. // Rationale: auto-mapping completed → review is opinionated and may confuse users // who expect 'done' to mean 'done'. // KanbanTaskCard: interface KanbanTaskCardProps { task: TeamTask; kanbanState?: KanbanTaskState; onMoveToColumn: (column: KanbanColumnId) => void; isBlocked: boolean; // task.blockedBy.length > 0 } // Blocked tasks: полупрозрачный + иконка 🔒 + tooltip "Blocked by #X, #Y" ``` ### Step 18: MessageComposer + delivery status (ДОПОЛНЕН в v4) ```typescript interface MessageComposerProps { members: ResolvedTeamMember[]; onSend: (member: string, text: string, summary?: string) => Promise; sending: boolean; // from teamSlice.sendingMessage sendError: string | null; // from teamSlice.sendError lastResult: SendMessageResult | null; // from teamSlice.lastSendResult } // UI states: // 1. Idle: textarea + recipient select + "Send" button // 2. Sending: "Sending..." + disabled button + spinner // 3. Sent: "Delivered ✓" toast (3 sec) + clear textarea // 4. Error: "Failed: {error}" toast (red) + "Retry" button ``` ### Step 19: ReviewDialog (без изменений из v3) --- ## Write-Path Safety (обновлён в v4) ### Inbox Protocol (без изменений) - **Формат**: JSON array `[{from, text, timestamp, read, ...}, ...]` - **Доставка**: между turns, 1-30 сек задержка - **Поле `from`**: `"user"` (наше приложение всегда от имени юзера) - **Сообщения НЕ удаляются** ### Write Strategy (v4 — исправлена) ``` read JSON → parse → append message → atomicWriteAsync(tmp → fsync → rename) → verify(messageId) → retry (до 3 раз, exponential backoff) ``` | Уровень защиты | v3 | v4 | Изменение | |-----------------|----|----|-----------| | Atomic write | tmp + fsync + rename | + mkdir + 'r+' flag + EXDEV | Исправлены 3 бага | | Async write | Sync (блокирует event loop) | fs.promises (non-blocking) | Новое | | Retry | "до 5 попыток в UI" (не реализовано) | appendToInboxWithRetry, 3 попытки, backoff | Реализовано | | Orphan cleanup | "На startup" (не реализовано) | cleanupOrphanTmpFiles() в main/index.ts | Реализовано | | Kanban write | Обычный writeFileSync | atomicWriteSync | Исправлено | | messageId verify | Sync read-back | Async read-back | Обновлено | ### Что НЕ нужно на MVP (без изменений) - File locking - Append-only JSONL - Separate .ui-inbox.json - Compare-And-Swap --- ## Phase 3: Testing (NEW в v4) ### Тестовая стратегия ``` test/ ├── main/ │ ├── services/team/ │ │ ├── TeamConfigReader.test.ts — listTeams, getConfig, missing dirs │ │ ├── TeamTaskReader.test.ts — getTasks, skip .lock/.highwatermark │ │ ├── TeamInboxReader.test.ts — getMessages, sendMessage, verify │ │ ├── TeamMemberResolver.test.ts — resolveMembers, status detection │ │ ├── TeamKanbanManager.test.ts — CRUD, GC, atomic write │ │ ├── TeamDataService.test.ts — orchestration, Promise.allSettled │ │ └── atomicWrite.test.ts — sync, async, fsync, EXDEV, race │ └── ipc/ │ ├── teams.test.ts — guard, all handlers, wrapTeamHandler │ └── guards.test.ts (extend) — validateTeamName, validateTaskId ├── renderer/ │ ├── utils/ │ │ └── unwrapIpc.test.ts — unwrapIpc, IpcError wrapping │ ├── store/ │ │ └── teamSlice.test.ts — actions, generation pattern, redirect │ └── components/team/ │ └── KanbanBoard.test.ts — column mapping, blocked tasks ├── fixtures/team/ │ ├── config.json — sample team config │ ├── task-001.json — sample task │ ├── member-inbox.json — sample inbox (5 messages) │ ├── kanban-state.json — sample kanban state │ └── corrupted/ — invalid JSON samples └── mocks/ └── teamFixtures.ts — createMockTeamConfig, createMockTeamTask, etc. ``` ### Приоритеты | Priority | Файлы | Что покрывают | |----------|-------|---------------| | P0 (must) | atomicWrite, TeamInboxReader, TeamDataService, teamSlice, teams.test | Core write-path + store | | P1 (should) | ConfigReader, TaskReader, MemberResolver, KanbanManager, unwrapIpc | Все readers + утилиты | | P2 (nice) | KanbanBoard, guards extension | UI + validation | --- ## File Change Summary ### New Files (~33) | # | File | Purpose | |---|------|---------| | 1 | `src/shared/types/ipc.ts` | IpcResult (deduplicated) | | 2 | `src/shared/types/team.ts` | Shared types | | 3 | `src/main/services/team/interfaces.ts` | 5 интерфейсов (NEW v4) | | 4 | `src/main/services/team/TeamConfigReader.ts` | Read config.json | | 5 | `src/main/services/team/TeamTaskReader.ts` | Read task files | | 6 | `src/main/services/team/TeamInboxReader.ts` | Read/write inbox | | 7 | `src/main/services/team/TeamMemberResolver.ts` | Resolve members | | 8 | `src/main/services/team/TeamKanbanManager.ts` | Kanban state CRUD | | 9 | `src/main/services/team/TeamDataService.ts` | Facade | | 10 | `src/main/services/team/TeamDataServiceFactory.ts` | Composition root (NEW v4) | | 11 | `src/main/services/team/atomicWrite.ts` | Atomic write utils | | 12 | `src/main/services/team/index.ts` | Barrel | | 13 | `src/main/ipc/teams.ts` | IPC handlers | | 14 | `src/renderer/utils/unwrapIpc.ts` | IPC error utility | | 15 | `src/renderer/store/slices/teamSlice.ts` | Store slice | | 16-30 | `src/renderer/components/team/**` | 15 UI components (+3 empty states) | | 31 | `test/mocks/teamFixtures.ts` | Test fixtures | | 32 | `test/fixtures/team/*` | Sample data | ### Modified Files (~18) | # | File | Change | |---|------|--------| | 1 | `src/shared/types/index.ts` | Re-export team types + IpcResult | | 2 | `src/shared/types/api.ts` | TeamsAPI + ElectronAPI | | 3 | `src/main/ipc/config.ts` | Replace ConfigResult → IpcResult | | 4 | `src/main/utils/pathDecoder.ts` | getTeamsBasePath(), getTasksBasePath() | | 5 | `src/main/services/index.ts` | Barrel export | | 6 | ~~`src/main/services/infrastructure/ServiceContext.ts`~~ | ~~v4: + teamDataService~~ v6: НЕ модифицируем | | 7 | `src/main/ipc/handlers.ts` | Wire team init/register/remove + teamDataService param (v7 #48) | | 8 | `src/main/ipc/guards.ts` | validateTeamName, validateTaskId, validateMemberName (v7 #39) | | 9 | `src/preload/constants/ipcChannels.ts` | TEAM_* channels (flat export const, v7 #36) | | 10 | `src/preload/index.ts` | teams API bridge + IpcResult from @shared | | 11 | `src/main/services/infrastructure/FileWatcher.ts` | TWO watchers: teamsWatcher + tasksWatcher (v7 #35) | | 12 | `src/main/index.ts` | Forward events, create service, orphan cleanup, httpServer.broadcast (v7 #44) | | 13 | `src/renderer/types/tabs.ts` | Discriminated union + TeamsTab (v7 #43) | | 14 | `src/renderer/store/types.ts` | TeamSlice | | 15 | `src/renderer/store/index.ts` | Compose + team-change listener + throttle (v7 #46) | | 16 | `src/renderer/components/layout/PaneContent.tsx` | TeamsTab + TeamTab rendering | | 17 | `src/renderer/api/httpClient.ts` | teams methods for browser mode | | 18 | `test/mocks/electronAPI.ts` | + teams mock methods | --- ## Architecture Diagram (v6) ``` ┌──────────────────── RENDERER ────────────────────┐ │ │ │ Components ──→ teamSlice ──→ unwrapIpc() │ │ (TeamView, (state + (catch+wrap, │ │ KanbanBoard, generation NO double unwrap) │ │ empty states) + cleanup) │ │ │ │ │ ↓ window.electronAPI.teams │ ├───────────────── PRELOAD BRIDGE ─────────────────┤ │ │ │ TeamsAPI interface ←→ IPC Channels │ │ invokeIpcWithResult() already handles IpcResult │ │ │ ├──────────────────── MAIN PROCESS ────────────────┤ │ │ │ teams.ts ──→ TeamDataService (Facade) │ │ (module-level ┌──────────────────────────┐ │ │ + wrapHandler) │ INTERFACES (DI/testing): │ │ │ │ ITeamConfigReader │ │ │ GLOBAL instance │ ITeamTaskReader │ │ │ (v6: не в │ ITeamInboxReader │ │ │ ServiceContext) │ ITeamMemberResolver │ │ │ │ ITeamKanbanManager │ │ │ └──────┬───────────────────┘ │ │ │ atomicWrite │ │ │ (async + retry) │ │ │ │ FileWatcher ──→ 'team-change' ──→ renderer │ │ (v7: TWO new + httpServer.broadcast │ │ watchers: v6: INSIDE wireFileWatcherEvents│ │ teamsWatcher │ │ + tasksWatcher) │ │ │ ├──────────────────── SHARED ──────────────────────┤ │ types/ipc.ts (IpcResult) │ │ types/team.ts (interfaces, discriminated unions) │ └───────────────────────────────────────────────────┘ ``` --- ## Verification 1. `pnpm typecheck` — типы компилируются (включая Tab discriminated union) 2. `pnpm dev` — Teams tab открывается, список / пустое состояние 3. Kanban: задачи по 5 колонкам из status + kanban-state 4. Click-to-move: select колонку → задача перемещается 5. Blocked tasks: визуальный индикатор + tooltip 6. Review: Approve/Request Changes badges 7. Messaging: отправка с delivery status (Sending → Sent/Error) 8. Live updates: изменение task-файла → UI обновляется (FileWatcher → team:change → store) 9. Team deletion: graceful redirect через flag (не setTimeout) 10. `pnpm lint:fix && pnpm format` 11. `pnpm test` — тесты не сломаны 12. `pnpm test:teams` — новые тесты проходят --- ## Integration Checklist (v5 — по результатам end-to-end трассировки) > Агент протрассировал полный путь данных от файла до экрана. > 12 точек интеграции с exact file:line references. ### Existing Pattern (Session flow — reference): ``` FileWatcher.emit('file-change') → src/main/services/infrastructure/FileWatcher.ts:552 main/index.ts sends to renderer → src/main/index.ts:121 (webContents.send) preload exposes onFileChange → src/preload/index.ts:334 store listener triggers refresh → src/renderer/store/index.ts:208 ``` ### Team Integration Points (ALL 12): | # | Что | Файл | Действие | |---|-----|------|----------| | 1 | IPC channel constants | `src/preload/constants/ipcChannels.ts` (EOF) | Добавить `TEAM_*` (6 flat `export const`, v7 #36) | | 2 | Preload API methods | `src/preload/index.ts` (~line 461) | `teams: { getData, list, sendMessage, onTeamChange }` | | 3 | Preload event listener | `src/preload/index.ts` (~line 463) | `ipcRenderer.on('team-change', ...)` | | 4 | API TypeScript types | `src/shared/types/api.ts` (~line 416) | `TeamsAPI` interface + extend `ElectronAPI` | | 5 | Handler module | `src/main/ipc/teams.ts` (NEW) | 3 functions: initialize, register, remove | | 6 | Handler registration | `src/main/ipc/handlers.ts` (lines 19-98) | Import + initialize + register + remove | | 7 | Event forwarding | `src/main/index.ts` (lines 105-139) | Wire 'team-change' like 'file-change' | | 8 | FileWatcher emission | `src/main/services/infrastructure/FileWatcher.ts` | Emit 'team-change' events | | 9 | Store slice creation | `src/renderer/store/slices/teamSlice.ts` (NEW) | Create TeamSlice | | 10 | Store composition | `src/renderer/store/index.ts` (lines 32-48) | Import + compose | | 11 | Store listener | `src/renderer/store/index.ts` (lines 208-349) | `api.teams?.onTeamChange()` | | 12 | Store types | `src/renderer/store/types.ts` | Extend AppState with TeamSlice | ### Critical Gotchas (из трассировки): - Preload использует `contextBridge.exposeInMainWorld()` → все данные ДОЛЖНЫ быть JSON-serializable - IPC channels — hardcoded strings, typos fail silently → использовать constants - `registry.getActive()` в handlers → ServiceContext scope-aware - Event broadcast: `.send()` (fire-forget), НЕ `.invoke()` (request-response) - Cleanup функции onTeamChange → PUSH в cleanupFns array → return в useEffect ### Verification Order: ```bash 1. grep -r 'teams:' src/preload/constants/ # channels exist 2. grep -r 'teams\.' src/preload/index.ts # API methods exist 3. grep -r 'team-change' src/main/ # event forwarded 4. grep -r 'onTeamChange' src/renderer/ # store listens 5. pnpm typecheck # types compile 6. pnpm dev → open team tab → change file # live update works ``` --- ## Write-Path Safety: In-Process Mutex (v5) ### Проблема ``` Time IPC Call 1 IPC Call 2 File State 1 Read [A] - [A] 2 - Read [A] [A] 3 Write [A,B] - [A,B] 4 - Write [A,C] [A,C] ← B LOST! ``` ### Решение: InboxWriteQueue (v5) ``` Time IPC Call 1 IPC Call 2 File State 1 Acquire lock Wait... [A] 2 Read [A] Wait... [A] 3 Write [A,B] Wait... [A,B] 4 Release lock Acquire lock [A,B] 5 - Read [A,B] [A,B] 6 - Write [A,B,C] [A,B,C] ← OK! ``` ### Scope: - **In-process mutex**: Решает races между concurrent IPC calls (99% случаев) - **Cross-process (CLI)**: НЕ решено, но verify + retry ловят потерянные messages - **Future**: JSONL append-only формат (Phase 2) устраняет проблему полностью --- ## Phase 2 (после MVP) - @dnd-kit drag-and-drop для kanban - Auto-review mapping: completed tasks → 'review' column (v7 #41 deferred to Phase 2) - reviewHistory + round-robin - State machine для member status - Inbox archival (>1000 сообщений) - FileWatcher → generic WatcherRegistry (если 5-й watcher, v7 now has 4) - File locking для inbox (если race >1/day) - JSONL inbox format (eliminates read-modify-write entirely) - Virtual scrolling для 50+ tasks (react-virtual) - Keyboard shortcuts для kanban (Ctrl+M → move) - Search/filter в kanban (by owner, status) - SSH mode: "Last updated" timestamp + slower refresh - Structured inbox message types (discriminated union для ActivityTimeline) - Notifications: desktop + badge для новых сообщений - Per-tab team data (Phase 2 — MVP uses global selectedTeamData)