- Added core documentation for the Team Management feature, including iteration goals, implementation plans, and design specifications. - Introduced new files for team configuration, IPC channels, and UI components for team listing and Kanban board. - Established a clear definition of done and scope for the first iteration, focusing on visible results and graceful degradation in the UI. - Documented the messaging and task file formats, ensuring robust communication between team members. - Implemented a Kanban design with defined states and transitions for task management.
2145 lines
85 KiB
Markdown
2145 lines
85 KiB
Markdown
# 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<Tab, 'id' | 'createdAt'>` с 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<T>()` утилита | 15 slices вызывают api напрямую |
|
||
| **Line limits** | ≤100 строк/класс | Без строгих лимитов, избегать 300-400+ | Прагматизм |
|
||
|
||
### IpcResult<T> — дедупликация (без изменений из v3)
|
||
|
||
Тип дублируется: `ConfigResult<T>` в config.ts и `IpcResult<T>` в preload/index.ts.
|
||
Вынести в `@shared/types/ipc.ts` — единый источник правды.
|
||
|
||
---
|
||
|
||
## Порядок реализации
|
||
|
||
### Справочник шагов (Steps)
|
||
|
||
```
|
||
Phase 0: Подготовка
|
||
0.1 IpcResult<T> дедупликация → @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<T>() (ИСПРАВЛЕН: без 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<T> дедупликация (без изменений)
|
||
|
||
**Create** `src/shared/types/ipc.ts`
|
||
|
||
```typescript
|
||
export interface IpcResult<T = void> {
|
||
success: boolean;
|
||
data?: T;
|
||
error?: string;
|
||
}
|
||
```
|
||
|
||
**Modify** `src/shared/types/index.ts` — `export type { IpcResult } from './ipc';`
|
||
**Modify** `src/main/ipc/config.ts` — удалить `ConfigResult<T>`, импортировать `IpcResult<T>` из `@shared/types`
|
||
**Modify** `src/preload/index.ts` — удалить `IpcResult<T>`, импортировать из `@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<string, unknown>;
|
||
}
|
||
|
||
// === Наши типы ===
|
||
|
||
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<string, KanbanTaskState>;
|
||
}
|
||
|
||
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<TeamSummary[]>;
|
||
getConfig(teamName: string): Promise<TeamConfig | null>;
|
||
}
|
||
|
||
export interface ITeamTaskReader {
|
||
getTasks(teamName: string): Promise<TeamTask[]>;
|
||
}
|
||
|
||
export interface ITeamInboxReader {
|
||
getInboxNames(teamName: string): Promise<string[]>;
|
||
getMessages(teamName: string): Promise<InboxMessage[]>;
|
||
getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]>;
|
||
sendMessage(
|
||
teamName: string,
|
||
member: string,
|
||
msg: { from: string; text: string; summary?: string }
|
||
): Promise<SendMessageResult>;
|
||
}
|
||
|
||
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<KanbanState>;
|
||
updateTaskState(teamName: string, taskId: string, state: Partial<KanbanTaskState>): Promise<void>;
|
||
removeTaskState(teamName: string, taskId: string): Promise<void>;
|
||
garbageCollect(teamName: string, existingTaskIds: Set<string>): Promise<void>;
|
||
}
|
||
```
|
||
|
||
#### TeamConfigReader
|
||
|
||
```typescript
|
||
export class TeamConfigReader implements ITeamConfigReader {
|
||
constructor(private readonly teamsBasePath: string) {}
|
||
|
||
async listTeams(): Promise<TeamSummary[]> {
|
||
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<TeamConfig | null> {
|
||
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<TeamTask[]> {
|
||
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<string[]> { /* readdir inboxes/ */ }
|
||
async getMessages(teamName: string): Promise<InboxMessage[]> { /* merge all, sort by timestamp */ }
|
||
async getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]> { /* 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<SendMessageResult> {
|
||
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<KanbanState> {
|
||
// read kanban-state.json, return default if missing
|
||
}
|
||
|
||
async updateTaskState(teamName: string, taskId: string, state: Partial<KanbanTaskState>): Promise<void> {
|
||
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<void> {
|
||
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<string>): Promise<void> {
|
||
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<void> {
|
||
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 <T> to avoid closure tricks for return values.
|
||
*/
|
||
const inboxWriteLocks = new Map<string, Promise<void>>();
|
||
|
||
export async function withInboxLock<T>(
|
||
inboxPath: string,
|
||
fn: () => Promise<T>
|
||
): Promise<T> {
|
||
// 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<void>(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<string, unknown>,
|
||
maxRetries: number = 3
|
||
): Promise<string> {
|
||
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<string, unknown>
|
||
): Promise<string> {
|
||
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<void> {
|
||
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<TeamSummary[]> {
|
||
return this.configReader.listTeams();
|
||
}
|
||
|
||
async getTeamData(teamName: string): Promise<TeamData> {
|
||
// 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<SendMessageResult> {
|
||
return this.inboxReader.sendMessage(teamName, member, msg);
|
||
}
|
||
|
||
async updateKanban(
|
||
teamName: string,
|
||
taskId: string,
|
||
state: Partial<KanbanTaskState>
|
||
): Promise<void> {
|
||
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<void> {
|
||
// 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<T>(
|
||
operation: string,
|
||
handler: () => Promise<T>
|
||
): Promise<IpcResult<T>> {
|
||
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<IpcResult<TeamSummary[]>> {
|
||
return wrapTeamHandler('list', () => getService().listTeams());
|
||
}
|
||
|
||
async function handleGetData(
|
||
_event: IpcMainInvokeEvent,
|
||
teamName: unknown
|
||
): Promise<IpcResult<TeamData>> {
|
||
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<IpcResult<SendMessageResult>> {
|
||
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<string> {
|
||
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<string> {
|
||
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<string> {
|
||
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<T>() (ИСПРАВЛЕН: без 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<T>(
|
||
operation: string,
|
||
fn: () => Promise<T>
|
||
): Promise<T> {
|
||
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<T>, 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<union> не дистрибутивен в TypeScript)
|
||
export type SessionTabInput = Omit<SessionTab, 'id' | 'createdAt'>;
|
||
export type TeamsTabInput = Omit<TeamsTab, 'id' | 'createdAt'>;
|
||
export type TeamTabInput = Omit<TeamTab, 'id' | 'createdAt'>;
|
||
export type DashboardTabInput = Omit<DashboardTab, 'id' | 'createdAt'>;
|
||
export type NotificationsTabInput = Omit<NotificationsTab, 'id' | 'createdAt'>;
|
||
export type SettingsTabInput = Omit<SettingsTab, 'id' | 'createdAt'>;
|
||
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<string, number>();
|
||
|
||
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<void>;
|
||
selectTeam: (teamName: string) => Promise<void>;
|
||
refreshTeamData: (teamName: string) => Promise<void>;
|
||
sendTeamMessage: (member: string, text: string, summary?: string) => Promise<void>;
|
||
moveTaskToColumn: (taskId: string, state: Partial<KanbanTaskState>) => Promise<void>;
|
||
openTeamTab: (teamName: string) => void;
|
||
openTeamsListTab: () => void;
|
||
/** v4: cleanup generation map при закрытии tab */
|
||
cleanupTeamState: (teamName: string) => void;
|
||
clearTeamDeletedRedirect: () => void;
|
||
}
|
||
|
||
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<typeof setTimeout> | 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) && <TeamView />}
|
||
{isTeamTab(tab) && <TeamView teamName={tab.teamName} />}
|
||
```
|
||
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<void>;
|
||
onRequestReview: (taskId: string) => void;
|
||
}
|
||
|
||
// TeamDetailView пробрасывает:
|
||
<KanbanBoard
|
||
tasks={selectedTeamData.tasks}
|
||
kanbanState={selectedTeamData.kanbanState}
|
||
onMoveTask={(taskId, column) => 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<void>;
|
||
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<T> (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<T>) │
|
||
│ 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)
|