Major restructuring: - Split monolithic gatewayStore into 5 focused stores: - connectionStore: WebSocket connection and gateway lifecycle - configStore: quickConfig, workspaceInfo, MCP services - agentStore: clones, usage stats, agent management - handStore: hands, approvals, triggers, hand runs - workflowStore: workflows, workflow runs, execution - Update all components to use new stores with selector pattern - Remove
609 lines
17 KiB
TypeScript
609 lines
17 KiB
TypeScript
/**
|
|
* Team Store - Multi-Agent Team Collaboration State Management
|
|
*
|
|
* Manages team orchestration, task assignment, Dev↔QA loops,
|
|
* and real-time collaboration state.
|
|
*
|
|
* @module store/teamStore
|
|
*/
|
|
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import type {
|
|
Team,
|
|
TeamMember,
|
|
TeamTask,
|
|
TeamTaskStatus,
|
|
TeamMemberRole,
|
|
DevQALoop,
|
|
DevQALoopState,
|
|
CreateTeamRequest,
|
|
AddTeamTaskRequest,
|
|
TeamMetrics,
|
|
CollaborationEvent,
|
|
ReviewFeedback,
|
|
TaskDeliverable,
|
|
} from '../types/team';
|
|
// === Store State ===
|
|
|
|
interface TeamStoreState {
|
|
// Data
|
|
teams: Team[];
|
|
activeTeam: Team | null;
|
|
metrics: TeamMetrics | null;
|
|
|
|
// UI State
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
selectedTaskId: string | null;
|
|
selectedMemberId: string | null;
|
|
|
|
// Real-time events
|
|
recentEvents: CollaborationEvent[];
|
|
|
|
// Actions - Team Management
|
|
loadTeams: () => Promise<void>;
|
|
createTeam: (request: CreateTeamRequest) => Promise<Team | null>;
|
|
deleteTeam: (teamId: string) => Promise<boolean>;
|
|
setActiveTeam: (team: Team | null) => void;
|
|
|
|
// Actions - Member Management
|
|
addMember: (teamId: string, agentId: string, role: TeamMemberRole) => Promise<TeamMember | null>;
|
|
removeMember: (teamId: string, memberId: string) => Promise<boolean>;
|
|
updateMemberRole: (teamId: string, memberId: string, role: TeamMemberRole) => Promise<boolean>;
|
|
|
|
// Actions - Task Management
|
|
addTask: (request: AddTeamTaskRequest) => Promise<TeamTask | null>;
|
|
updateTaskStatus: (teamId: string, taskId: string, status: TeamTaskStatus) => Promise<boolean>;
|
|
assignTask: (teamId: string, taskId: string, memberId: string) => Promise<boolean>;
|
|
submitDeliverable: (teamId: string, taskId: string, deliverable: TaskDeliverable) => Promise<boolean>;
|
|
|
|
// Actions - Dev↔QA Loop
|
|
startDevQALoop: (teamId: string, taskId: string, developerId: string, reviewerId: string) => Promise<DevQALoop | null>;
|
|
submitReview: (teamId: string, loopId: string, feedback: Omit<ReviewFeedback, 'reviewedAt' | 'reviewerId'>) => Promise<boolean>;
|
|
updateLoopState: (teamId: string, loopId: string, state: DevQALoopState) => Promise<boolean>;
|
|
|
|
// Actions - Events
|
|
addEvent: (event: CollaborationEvent) => void;
|
|
clearEvents: () => void;
|
|
|
|
// Actions - UI
|
|
setSelectedTask: (taskId: string | null) => void;
|
|
setSelectedMember: (memberId: string | null) => void;
|
|
clearError: () => void;
|
|
}
|
|
|
|
// === Helper Functions ===
|
|
|
|
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
const calculateMetrics = (team: Team): TeamMetrics => {
|
|
const completedTasks = team.tasks.filter(t => t.status === 'completed');
|
|
const totalTasks = team.tasks.length;
|
|
const reviewedTasks = completedTasks.filter(t => t.reviewFeedback);
|
|
|
|
const avgCompletionTime = completedTasks.length > 0
|
|
? completedTasks.reduce((sum, t) => {
|
|
if (t.startedAt && t.completedAt) {
|
|
return sum + (new Date(t.completedAt).getTime() - new Date(t.startedAt).getTime());
|
|
}
|
|
return sum;
|
|
}, 0) / completedTasks.length
|
|
: 0;
|
|
|
|
const approvedReviews = reviewedTasks.filter(t => t.reviewFeedback?.verdict === 'approved');
|
|
const passRate = reviewedTasks.length > 0
|
|
? (approvedReviews.length / reviewedTasks.length) * 100
|
|
: 0;
|
|
|
|
const totalIterations = team.activeLoops.reduce((sum, loop) => sum + loop.iterationCount, 0);
|
|
const avgIterations = team.activeLoops.length > 0
|
|
? totalIterations / team.activeLoops.length
|
|
: 0;
|
|
|
|
const escalations = team.activeLoops.filter(loop => loop.state === 'escalated').length;
|
|
|
|
const efficiency = totalTasks > 0
|
|
? Math.min(100, (completedTasks.length / totalTasks) * 100 * (passRate / 100))
|
|
: 0;
|
|
|
|
return {
|
|
tasksCompleted: completedTasks.length,
|
|
avgCompletionTime,
|
|
passRate,
|
|
avgIterations,
|
|
escalations,
|
|
efficiency,
|
|
};
|
|
};
|
|
|
|
// === Store Implementation ===
|
|
|
|
export const useTeamStore = create<TeamStoreState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
// Initial State
|
|
teams: [],
|
|
activeTeam: null,
|
|
metrics: null,
|
|
isLoading: false,
|
|
error: null,
|
|
selectedTaskId: null,
|
|
selectedMemberId: null,
|
|
recentEvents: [],
|
|
|
|
// Team Management
|
|
loadTeams: async () => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
// For now, load from localStorage until API is available
|
|
// Note: persist middleware stores data as { state: { teams: [...] }, version: ... }
|
|
const stored = localStorage.getItem('zclaw-teams');
|
|
let teams: Team[] = [];
|
|
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
// Handle persist middleware format
|
|
if (parsed?.state?.teams && Array.isArray(parsed.state.teams)) {
|
|
teams = parsed.state.teams;
|
|
} else if (Array.isArray(parsed)) {
|
|
// Direct array format (legacy)
|
|
teams = parsed;
|
|
}
|
|
}
|
|
|
|
set({ teams, isLoading: false });
|
|
} catch (error) {
|
|
console.error('[TeamStore] Failed to load teams:', error);
|
|
set({ teams: [], isLoading: false });
|
|
}
|
|
},
|
|
|
|
createTeam: async (request: CreateTeamRequest) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const now = new Date().toISOString();
|
|
const members: TeamMember[] = request.memberAgents.map((m) => ({
|
|
id: generateId(),
|
|
agentId: m.agentId,
|
|
name: `Agent-${m.agentId.slice(0, 4)}`,
|
|
role: m.role,
|
|
skills: [],
|
|
workload: 0,
|
|
status: 'idle',
|
|
maxConcurrentTasks: m.role === 'orchestrator' ? 5 : 2,
|
|
currentTasks: [],
|
|
}));
|
|
|
|
const team: Team = {
|
|
id: generateId(),
|
|
name: request.name,
|
|
description: request.description,
|
|
members,
|
|
tasks: [],
|
|
pattern: request.pattern,
|
|
activeLoops: [],
|
|
status: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
set(state => {
|
|
const teams = [...state.teams, team];
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return { teams, activeTeam: team, isLoading: false };
|
|
});
|
|
|
|
return team;
|
|
} catch (error) {
|
|
set({ error: (error as Error).message, isLoading: false });
|
|
return null;
|
|
}
|
|
},
|
|
|
|
deleteTeam: async (teamId: string) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
set(state => {
|
|
const teams = state.teams.filter(t => t.id !== teamId);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? null : state.activeTeam,
|
|
isLoading: false
|
|
};
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
set({ error: (error as Error).message, isLoading: false });
|
|
return false;
|
|
}
|
|
},
|
|
|
|
setActiveTeam: (team: Team | null) => {
|
|
set(() => ({
|
|
activeTeam: team,
|
|
metrics: team ? calculateMetrics(team) : null,
|
|
}));
|
|
},
|
|
|
|
// Member Management
|
|
addMember: async (teamId: string, agentId: string, role: TeamMemberRole) => {
|
|
const state = get();
|
|
const team = state.teams.find(t => t.id === teamId);
|
|
if (!team) return null;
|
|
|
|
const member: TeamMember = {
|
|
id: generateId(),
|
|
agentId,
|
|
name: `Agent-${agentId.slice(0, 4)}`,
|
|
role,
|
|
skills: [],
|
|
workload: 0,
|
|
status: 'idle',
|
|
maxConcurrentTasks: role === 'orchestrator' ? 5 : 2,
|
|
currentTasks: [],
|
|
};
|
|
|
|
const updatedTeam = {
|
|
...team,
|
|
members: [...team.members, member],
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? updatedTeam : state.activeTeam,
|
|
};
|
|
});
|
|
|
|
return member;
|
|
},
|
|
|
|
removeMember: async (teamId: string, memberId: string) => {
|
|
const state = get();
|
|
const team = state.teams.find(t => t.id === teamId);
|
|
if (!team) return false;
|
|
|
|
const updatedTeam = {
|
|
...team,
|
|
members: team.members.filter(m => m.id !== memberId),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? updatedTeam : state.activeTeam,
|
|
};
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
updateMemberRole: async (teamId: string, memberId: string, role: TeamMemberRole) => {
|
|
const state = get();
|
|
const team = state.teams.find(t => t.id === teamId);
|
|
if (!team) return false;
|
|
|
|
const updatedTeam = {
|
|
...team,
|
|
members: team.members.map(m =>
|
|
m.id === memberId ? { ...m, role, maxConcurrentTasks: role === 'orchestrator' ? 5 : 2 } : m
|
|
),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? updatedTeam : state.activeTeam,
|
|
};
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
// Task Management
|
|
addTask: async (request: AddTeamTaskRequest) => {
|
|
const state = get();
|
|
const team = state.teams.find(t => t.id === request.teamId);
|
|
if (!team) return null;
|
|
|
|
const now = new Date().toISOString();
|
|
const task: TeamTask = {
|
|
id: generateId(),
|
|
title: request.title,
|
|
description: request.description,
|
|
status: request.assigneeId ? 'assigned' : 'pending',
|
|
priority: request.priority,
|
|
assigneeId: request.assigneeId,
|
|
dependencies: request.dependencies || [],
|
|
type: request.type,
|
|
estimate: request.estimate,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
const updatedTeam = {
|
|
...team,
|
|
tasks: [...team.tasks, task],
|
|
updatedAt: now,
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === request.teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === request.teamId ? updatedTeam : state.activeTeam,
|
|
metrics: state.activeTeam?.id === request.teamId ? calculateMetrics(updatedTeam) : state.metrics,
|
|
};
|
|
});
|
|
|
|
return task;
|
|
},
|
|
|
|
updateTaskStatus: async (teamId: string, taskId: string, status: TeamTaskStatus) => {
|
|
const state = get();
|
|
const team = state.teams.find(t => t.id === teamId);
|
|
if (!team) return false;
|
|
|
|
const now = new Date().toISOString();
|
|
const updatedTeam = {
|
|
...team,
|
|
tasks: team.tasks.map(t => {
|
|
if (t.id !== taskId) return t;
|
|
const updates: Partial<TeamTask> = { status, updatedAt: now };
|
|
if (status === 'in_progress' && !t.startedAt) {
|
|
updates.startedAt = now;
|
|
}
|
|
if (status === 'completed') {
|
|
updates.completedAt = now;
|
|
}
|
|
return { ...t, ...updates };
|
|
}),
|
|
updatedAt: now,
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? updatedTeam : state.activeTeam,
|
|
metrics: state.activeTeam?.id === teamId ? calculateMetrics(updatedTeam) : state.metrics,
|
|
};
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
assignTask: async (teamId: string, taskId: string, memberId: string) => {
|
|
const state = get();
|
|
const team = state.teams.find(t => t.id === teamId);
|
|
if (!team) return false;
|
|
|
|
const now = new Date().toISOString();
|
|
const updatedTeam = {
|
|
...team,
|
|
tasks: team.tasks.map(t =>
|
|
t.id === taskId
|
|
? { ...t, assigneeId: memberId, status: 'assigned' as TeamTaskStatus, updatedAt: now }
|
|
: t
|
|
),
|
|
members: team.members.map(m =>
|
|
m.id === memberId
|
|
? { ...m, currentTasks: [...m.currentTasks, taskId], workload: (m.workload + 25) }
|
|
: m
|
|
),
|
|
updatedAt: now,
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? updatedTeam : state.activeTeam,
|
|
};
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
submitDeliverable: async (teamId: string, taskId: string, deliverable: TaskDeliverable) => {
|
|
const state = get();
|
|
const team = state.teams.find(t => t.id === teamId);
|
|
if (!team) return false;
|
|
|
|
const now = new Date().toISOString();
|
|
const updatedTeam = {
|
|
...team,
|
|
tasks: team.tasks.map(t =>
|
|
t.id === taskId
|
|
? { ...t, deliverable, status: 'review' as TeamTaskStatus, updatedAt: now }
|
|
: t
|
|
),
|
|
updatedAt: now,
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? updatedTeam : state.activeTeam,
|
|
};
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
// Dev↔QA Loop
|
|
startDevQALoop: async (teamId: string, taskId: string, developerId: string, reviewerId: string) => {
|
|
const state = get();
|
|
const team = state.teams.find(t => t.id === teamId);
|
|
if (!team) return null;
|
|
|
|
const now = new Date().toISOString();
|
|
const loop: DevQALoop = {
|
|
id: generateId(),
|
|
developerId,
|
|
reviewerId,
|
|
taskId,
|
|
state: 'developing',
|
|
iterationCount: 0,
|
|
maxIterations: 3,
|
|
feedbackHistory: [],
|
|
startedAt: now,
|
|
lastUpdatedAt: now,
|
|
};
|
|
|
|
const updatedTeam = {
|
|
...team,
|
|
activeLoops: [...team.activeLoops, loop],
|
|
updatedAt: now,
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? updatedTeam : state.activeTeam,
|
|
};
|
|
});
|
|
|
|
return loop;
|
|
},
|
|
|
|
submitReview: async (teamId: string, loopId: string, feedback: Omit<ReviewFeedback, 'reviewedAt' | 'reviewerId'>) => {
|
|
const state = get();
|
|
const team = state.teams.find(t => t.id === teamId);
|
|
if (!team) return false;
|
|
|
|
const loop = team.activeLoops.find(l => l.id === loopId);
|
|
if (!loop) return false;
|
|
|
|
const now = new Date().toISOString();
|
|
const fullFeedback: ReviewFeedback = {
|
|
...feedback,
|
|
reviewedAt: now,
|
|
reviewerId: loop.reviewerId,
|
|
};
|
|
|
|
let newState: DevQALoopState;
|
|
let newIterationCount = loop.iterationCount;
|
|
|
|
if (feedback.verdict === 'approved') {
|
|
newState = 'approved';
|
|
} else if (newIterationCount >= loop.maxIterations - 1) {
|
|
newState = 'escalated';
|
|
} else {
|
|
newState = 'revising';
|
|
newIterationCount++;
|
|
}
|
|
|
|
const updatedTeam = {
|
|
...team,
|
|
tasks: team.tasks.map(t =>
|
|
t.id === loop.taskId
|
|
? { ...t, reviewFeedback: fullFeedback, updatedAt: now }
|
|
: t
|
|
),
|
|
activeLoops: team.activeLoops.map(l =>
|
|
l.id === loopId
|
|
? {
|
|
...l,
|
|
state: newState,
|
|
iterationCount: newIterationCount,
|
|
feedbackHistory: [...l.feedbackHistory, fullFeedback],
|
|
lastUpdatedAt: now,
|
|
}
|
|
: l
|
|
),
|
|
updatedAt: now,
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? updatedTeam : state.activeTeam,
|
|
metrics: state.activeTeam?.id === teamId ? calculateMetrics(updatedTeam) : state.metrics,
|
|
};
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
updateLoopState: async (teamId: string, loopId: string, state: DevQALoopState) => {
|
|
const teamStore = get();
|
|
const team = teamStore.teams.find(t => t.id === teamId);
|
|
if (!team) return false;
|
|
|
|
const now = new Date().toISOString();
|
|
const updatedTeam = {
|
|
...team,
|
|
activeLoops: team.activeLoops.map(l =>
|
|
l.id === loopId
|
|
? { ...l, state, lastUpdatedAt: now }
|
|
: l
|
|
),
|
|
updatedAt: now,
|
|
};
|
|
|
|
set(state => {
|
|
const teams = state.teams.map(t => t.id === teamId ? updatedTeam : t);
|
|
localStorage.setItem('zclaw-teams', JSON.stringify(teams));
|
|
return {
|
|
teams,
|
|
activeTeam: state.activeTeam?.id === teamId ? updatedTeam : state.activeTeam,
|
|
};
|
|
});
|
|
|
|
return true;
|
|
},
|
|
|
|
// Events
|
|
addEvent: (event: CollaborationEvent) => {
|
|
set(state => ({
|
|
recentEvents: [event, ...state.recentEvents].slice(0, 100),
|
|
}));
|
|
},
|
|
|
|
clearEvents: () => {
|
|
set({ recentEvents: [] });
|
|
},
|
|
|
|
// UI
|
|
setSelectedTask: (taskId: string | null) => {
|
|
set({ selectedTaskId: taskId });
|
|
},
|
|
|
|
setSelectedMember: (memberId: string | null) => {
|
|
set({ selectedMemberId: memberId });
|
|
},
|
|
|
|
clearError: () => {
|
|
set({ error: null });
|
|
},
|
|
}),
|
|
{
|
|
name: 'zclaw-teams',
|
|
partialize: (state) => ({
|
|
teams: state.teams,
|
|
activeTeam: state.activeTeam,
|
|
}),
|
|
},
|
|
));
|