refactor: 移除 Team 和 Swarm 协作功能
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
功能论证结论:Team(团队)和 Swarm(协作)为零后端支持的 纯前端 localStorage 空壳,Pipeline 系统已完全覆盖其全部能力。 删除 16 个文件,约 7,950 行代码: - 5 个组件:TeamCollaborationView, TeamOrchestrator, TeamList, DevQALoop, SwarmDashboard - 1 个 Store:teamStore.ts - 3 个 Client/库:team-client.ts, useTeamEvents.ts, agent-swarm.ts - 1 个类型文件:team.ts - 4 个测试文件 - 1 个文档(归档 swarm-coordination.md) 修改 4 个文件: - Sidebar.tsx:移除"团队"和"协作"导航项 - App.tsx:移除 team/swarm 视图路由 - types/index.ts:移除 team 类型导出 - chatStore.ts:移除 dispatchSwarmTask 方法 更新 CHANGELOG.md 和功能文档 README.md
This commit is contained in:
@@ -1,549 +0,0 @@
|
||||
/**
|
||||
* Agent Swarm - Multi-Agent collaboration framework for ZCLAW
|
||||
*
|
||||
* Enables multiple agents (clones) to collaborate on complex tasks through:
|
||||
* - Sequential: Agents process in chain, each building on the previous
|
||||
* - Parallel: Agents work simultaneously on different subtasks
|
||||
* - Debate: Agents provide competing perspectives, coordinator synthesizes
|
||||
*
|
||||
* Integrates with existing Clone/Agent infrastructure via agentStore/gatewayStore.
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.5.1
|
||||
*/
|
||||
|
||||
import { intelligenceClient } from './intelligence-client';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type CommunicationStyle = 'sequential' | 'parallel' | 'debate';
|
||||
export type TaskDecomposition = 'auto' | 'manual';
|
||||
export type SubtaskStatus = 'pending' | 'running' | 'done' | 'failed';
|
||||
export type SwarmTaskStatus = 'planning' | 'executing' | 'aggregating' | 'done' | 'failed';
|
||||
|
||||
export interface SwarmSpecialist {
|
||||
agentId: string;
|
||||
role: string;
|
||||
capabilities: string[];
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SwarmConfig {
|
||||
coordinator: string;
|
||||
specialists: SwarmSpecialist[];
|
||||
taskDecomposition: TaskDecomposition;
|
||||
communicationStyle: CommunicationStyle;
|
||||
maxRoundsDebate: number;
|
||||
timeoutPerSubtaskMs: number;
|
||||
}
|
||||
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
assignedTo: string;
|
||||
description: string;
|
||||
status: SubtaskStatus;
|
||||
result?: string;
|
||||
error?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
round?: number;
|
||||
}
|
||||
|
||||
export interface SwarmTask {
|
||||
id: string;
|
||||
description: string;
|
||||
subtasks: Subtask[];
|
||||
status: SwarmTaskStatus;
|
||||
communicationStyle: CommunicationStyle;
|
||||
finalResult?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SwarmExecutionResult {
|
||||
task: SwarmTask;
|
||||
summary: string;
|
||||
participantCount: number;
|
||||
totalDurationMs: number;
|
||||
}
|
||||
|
||||
export type AgentExecutor = (
|
||||
agentId: string,
|
||||
prompt: string,
|
||||
context?: string
|
||||
) => Promise<string>;
|
||||
|
||||
// === Default Config ===
|
||||
|
||||
export const DEFAULT_SWARM_CONFIG: SwarmConfig = {
|
||||
coordinator: 'zclaw-main',
|
||||
specialists: [],
|
||||
taskDecomposition: 'auto',
|
||||
communicationStyle: 'sequential',
|
||||
maxRoundsDebate: 3,
|
||||
timeoutPerSubtaskMs: 60_000,
|
||||
};
|
||||
|
||||
// === Storage ===
|
||||
|
||||
const SWARM_HISTORY_KEY = 'zclaw-swarm-history';
|
||||
|
||||
// === Swarm Engine ===
|
||||
|
||||
export class AgentSwarm {
|
||||
private config: SwarmConfig;
|
||||
private history: SwarmTask[] = [];
|
||||
private executor: AgentExecutor | null = null;
|
||||
|
||||
constructor(config?: Partial<SwarmConfig>) {
|
||||
this.config = { ...DEFAULT_SWARM_CONFIG, ...config };
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the executor function used to dispatch prompts to individual agents.
|
||||
* This decouples the swarm from the gateway transport layer.
|
||||
*/
|
||||
setExecutor(executor: AgentExecutor): void {
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
// === Task Creation ===
|
||||
|
||||
/**
|
||||
* Create a new swarm task. If taskDecomposition is 'auto', subtasks are
|
||||
* generated based on specialist capabilities and the task description.
|
||||
*/
|
||||
createTask(
|
||||
description: string,
|
||||
options?: {
|
||||
subtasks?: Array<{ assignedTo: string; description: string }>;
|
||||
communicationStyle?: CommunicationStyle;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): SwarmTask {
|
||||
const style = options?.communicationStyle || this.config.communicationStyle;
|
||||
const taskId = `swarm_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
let subtasks: Subtask[];
|
||||
|
||||
if (options?.subtasks && options.subtasks.length > 0) {
|
||||
// Manual decomposition
|
||||
subtasks = options.subtasks.map((st, i) => ({
|
||||
id: `${taskId}_sub_${i}`,
|
||||
assignedTo: st.assignedTo,
|
||||
description: st.description,
|
||||
status: 'pending' as SubtaskStatus,
|
||||
}));
|
||||
} else {
|
||||
// Auto decomposition based on specialists
|
||||
subtasks = this.autoDecompose(taskId, description, style);
|
||||
}
|
||||
|
||||
const task: SwarmTask = {
|
||||
id: taskId,
|
||||
description,
|
||||
subtasks,
|
||||
status: 'planning',
|
||||
communicationStyle: style,
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a swarm task using the configured communication style.
|
||||
*/
|
||||
async execute(task: SwarmTask): Promise<SwarmExecutionResult> {
|
||||
if (!this.executor) {
|
||||
throw new Error('[AgentSwarm] No executor set. Call setExecutor() first.');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
task.status = 'executing';
|
||||
|
||||
try {
|
||||
switch (task.communicationStyle) {
|
||||
case 'sequential':
|
||||
await this.executeSequential(task);
|
||||
break;
|
||||
case 'parallel':
|
||||
await this.executeParallel(task);
|
||||
break;
|
||||
case 'debate':
|
||||
await this.executeDebate(task);
|
||||
break;
|
||||
}
|
||||
|
||||
// Aggregation phase
|
||||
task.status = 'aggregating';
|
||||
task.finalResult = await this.aggregate(task);
|
||||
task.status = 'done';
|
||||
task.completedAt = new Date().toISOString();
|
||||
} catch (err) {
|
||||
task.status = 'failed';
|
||||
task.finalResult = `执行失败: ${err instanceof Error ? err.message : String(err)}`;
|
||||
task.completedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
const totalDurationMs = Date.now() - startTime;
|
||||
|
||||
// Store in history
|
||||
this.history.push(task);
|
||||
if (this.history.length > 50) {
|
||||
this.history = this.history.slice(-25);
|
||||
}
|
||||
this.saveHistory();
|
||||
|
||||
// Save task result as memory
|
||||
try {
|
||||
await intelligenceClient.memory.store({
|
||||
agent_id: this.config.coordinator,
|
||||
memory_type: 'lesson',
|
||||
content: `协作任务完成: "${task.description}" — ${task.subtasks.length}个子任务, 模式: ${task.communicationStyle}, 结果: ${(task.finalResult || '').slice(0, 200)}`,
|
||||
importance: 6,
|
||||
source: 'auto',
|
||||
tags: ['swarm', task.communicationStyle],
|
||||
});
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
const result: SwarmExecutionResult = {
|
||||
task,
|
||||
summary: task.finalResult || '',
|
||||
participantCount: new Set(task.subtasks.map(s => s.assignedTo)).size,
|
||||
totalDurationMs,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[AgentSwarm] Task "${task.description}" completed in ${totalDurationMs}ms, ` +
|
||||
`${result.participantCount} participants, ${task.subtasks.length} subtasks`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// === Execution Strategies ===
|
||||
|
||||
/**
|
||||
* Sequential: Each agent runs in order, receiving the previous agent's output as context.
|
||||
*/
|
||||
private async executeSequential(task: SwarmTask): Promise<void> {
|
||||
let previousResult = '';
|
||||
|
||||
for (const subtask of task.subtasks) {
|
||||
subtask.status = 'running';
|
||||
subtask.startedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const context = previousResult
|
||||
? `前一个Agent的输出:\n${previousResult}\n\n请基于以上内容继续完成你的部分。`
|
||||
: '';
|
||||
|
||||
const result = await this.executeSubtask(subtask, context);
|
||||
subtask.result = result;
|
||||
subtask.status = 'done';
|
||||
previousResult = result;
|
||||
} catch (err) {
|
||||
subtask.status = 'failed';
|
||||
subtask.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
subtask.completedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parallel: All agents run simultaneously, results collected independently.
|
||||
*/
|
||||
private async executeParallel(task: SwarmTask): Promise<void> {
|
||||
const promises = task.subtasks.map(async (subtask) => {
|
||||
subtask.status = 'running';
|
||||
subtask.startedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const result = await this.executeSubtask(subtask);
|
||||
subtask.result = result;
|
||||
subtask.status = 'done';
|
||||
} catch (err) {
|
||||
subtask.status = 'failed';
|
||||
subtask.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
subtask.completedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debate: All agents respond to the same prompt in multiple rounds.
|
||||
* Each round, agents can see previous round results.
|
||||
*/
|
||||
private async executeDebate(task: SwarmTask): Promise<void> {
|
||||
const maxRounds = this.config.maxRoundsDebate;
|
||||
const agents = [...new Set(task.subtasks.map(s => s.assignedTo))];
|
||||
|
||||
for (let round = 1; round <= maxRounds; round++) {
|
||||
const roundContext = round > 1
|
||||
? this.buildDebateContext(task, round - 1)
|
||||
: '';
|
||||
|
||||
const roundPromises = agents.map(async (agentId) => {
|
||||
const subtaskId = `${task.id}_debate_r${round}_${agentId}`;
|
||||
const subtask: Subtask = {
|
||||
id: subtaskId,
|
||||
assignedTo: agentId,
|
||||
description: task.description,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
round,
|
||||
};
|
||||
|
||||
try {
|
||||
const prompt = round === 1
|
||||
? task.description
|
||||
: `这是第${round}轮讨论。请参考其他Agent的观点,给出你的更新后的分析。\n\n${roundContext}`;
|
||||
|
||||
const result = await this.executeSubtask(subtask, '', prompt);
|
||||
subtask.result = result;
|
||||
subtask.status = 'done';
|
||||
} catch (err) {
|
||||
subtask.status = 'failed';
|
||||
subtask.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
subtask.completedAt = new Date().toISOString();
|
||||
task.subtasks.push(subtask);
|
||||
});
|
||||
|
||||
await Promise.allSettled(roundPromises);
|
||||
|
||||
// Check if consensus reached (all agents give similar answers)
|
||||
if (round < maxRounds && this.checkConsensus(task, round)) {
|
||||
console.log(`[AgentSwarm] Debate consensus reached at round ${round}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
private async executeSubtask(
|
||||
subtask: Subtask,
|
||||
context?: string,
|
||||
promptOverride?: string
|
||||
): Promise<string> {
|
||||
if (!this.executor) throw new Error('No executor');
|
||||
|
||||
const prompt = promptOverride || subtask.description;
|
||||
return this.executor(subtask.assignedTo, prompt, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-decompose a task into subtasks based on specialist roles.
|
||||
*/
|
||||
private autoDecompose(
|
||||
taskId: string,
|
||||
description: string,
|
||||
style: CommunicationStyle
|
||||
): Subtask[] {
|
||||
const specialists = this.config.specialists;
|
||||
|
||||
if (specialists.length === 0) {
|
||||
// Single agent fallback
|
||||
return [{
|
||||
id: `${taskId}_sub_0`,
|
||||
assignedTo: this.config.coordinator,
|
||||
description,
|
||||
status: 'pending',
|
||||
}];
|
||||
}
|
||||
|
||||
if (style === 'debate') {
|
||||
// In debate mode, all specialists get the same task
|
||||
return specialists.map((spec, i) => ({
|
||||
id: `${taskId}_sub_${i}`,
|
||||
assignedTo: spec.agentId,
|
||||
description: `作为${spec.role},请分析以下问题: ${description}`,
|
||||
status: 'pending' as SubtaskStatus,
|
||||
round: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
if (style === 'parallel') {
|
||||
// In parallel, assign based on capabilities
|
||||
return specialists.map((spec, i) => ({
|
||||
id: `${taskId}_sub_${i}`,
|
||||
assignedTo: spec.agentId,
|
||||
description: `从${spec.role}的角度完成: ${description}`,
|
||||
status: 'pending' as SubtaskStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sequential: chain through specialists
|
||||
return specialists.map((spec, i) => ({
|
||||
id: `${taskId}_sub_${i}`,
|
||||
assignedTo: spec.agentId,
|
||||
description: i === 0
|
||||
? `作为${spec.role},请首先分析: ${description}`
|
||||
: `作为${spec.role},请基于前一位同事的分析继续深化: ${description}`,
|
||||
status: 'pending' as SubtaskStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
private buildDebateContext(task: SwarmTask, upToRound: number): string {
|
||||
const roundSubtasks = task.subtasks.filter(
|
||||
s => s.round && s.round <= upToRound && s.status === 'done'
|
||||
);
|
||||
|
||||
if (roundSubtasks.length === 0) return '';
|
||||
|
||||
const lines = roundSubtasks.map(s => {
|
||||
const specialist = this.config.specialists.find(sp => sp.agentId === s.assignedTo);
|
||||
const role = specialist?.role || s.assignedTo;
|
||||
return `**${role}** (第${s.round}轮):\n${s.result || '(无输出)'}`;
|
||||
});
|
||||
|
||||
return `其他Agent的观点:\n\n${lines.join('\n\n---\n\n')}`;
|
||||
}
|
||||
|
||||
private checkConsensus(task: SwarmTask, round: number): boolean {
|
||||
const roundResults = task.subtasks
|
||||
.filter(s => s.round === round && s.status === 'done' && s.result)
|
||||
.map(s => s.result!);
|
||||
|
||||
if (roundResults.length < 2) return false;
|
||||
|
||||
// Simple heuristic: if all results share > 60% keywords, consider consensus
|
||||
const tokenSets = roundResults.map(r =>
|
||||
new Set(r.toLowerCase().split(/[\s,;.!?。,;!?]+/).filter(t => t.length > 1))
|
||||
);
|
||||
|
||||
for (let i = 1; i < tokenSets.length; i++) {
|
||||
const common = [...tokenSets[0]].filter(t => tokenSets[i].has(t));
|
||||
const similarity = (2 * common.length) / (tokenSets[0].size + tokenSets[i].size);
|
||||
if (similarity < 0.6) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate subtask results into a final summary.
|
||||
* Phase 4: rule-based. Future: LLM-powered synthesis.
|
||||
*/
|
||||
private async aggregate(task: SwarmTask): Promise<string> {
|
||||
const completedSubtasks = task.subtasks.filter(s => s.status === 'done' && s.result);
|
||||
const failedSubtasks = task.subtasks.filter(s => s.status === 'failed');
|
||||
|
||||
if (completedSubtasks.length === 0) {
|
||||
return failedSubtasks.length > 0
|
||||
? `所有子任务失败: ${failedSubtasks.map(s => s.error).join('; ')}`
|
||||
: '无结果';
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
sections.push(`## 协作任务: ${task.description}`);
|
||||
sections.push(`**模式**: ${task.communicationStyle} | **参与者**: ${new Set(completedSubtasks.map(s => s.assignedTo)).size}`);
|
||||
|
||||
if (task.communicationStyle === 'debate') {
|
||||
// Group by round
|
||||
const maxRound = Math.max(...completedSubtasks.map(s => s.round || 1));
|
||||
for (let r = 1; r <= maxRound; r++) {
|
||||
const roundResults = completedSubtasks.filter(s => s.round === r);
|
||||
if (roundResults.length > 0) {
|
||||
sections.push(`\n### 第${r}轮讨论`);
|
||||
for (const s of roundResults) {
|
||||
const spec = this.config.specialists.find(sp => sp.agentId === s.assignedTo);
|
||||
sections.push(`**${spec?.role || s.assignedTo}**:\n${s.result}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const s of completedSubtasks) {
|
||||
const spec = this.config.specialists.find(sp => sp.agentId === s.assignedTo);
|
||||
sections.push(`\n### ${spec?.role || s.assignedTo}\n${s.result}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedSubtasks.length > 0) {
|
||||
sections.push(`\n### 失败的子任务 (${failedSubtasks.length})`);
|
||||
for (const s of failedSubtasks) {
|
||||
sections.push(`- ${s.assignedTo}: ${s.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
// === Query ===
|
||||
|
||||
getHistory(limit: number = 20): SwarmTask[] {
|
||||
return this.history.slice(-limit);
|
||||
}
|
||||
|
||||
getTask(taskId: string): SwarmTask | undefined {
|
||||
return this.history.find(t => t.id === taskId);
|
||||
}
|
||||
|
||||
getSpecialists(): SwarmSpecialist[] {
|
||||
return [...this.config.specialists];
|
||||
}
|
||||
|
||||
addSpecialist(specialist: SwarmSpecialist): void {
|
||||
const existing = this.config.specialists.findIndex(s => s.agentId === specialist.agentId);
|
||||
if (existing >= 0) {
|
||||
this.config.specialists[existing] = specialist;
|
||||
} else {
|
||||
this.config.specialists.push(specialist);
|
||||
}
|
||||
}
|
||||
|
||||
removeSpecialist(agentId: string): void {
|
||||
this.config.specialists = this.config.specialists.filter(s => s.agentId !== agentId);
|
||||
}
|
||||
|
||||
// === Config ===
|
||||
|
||||
getConfig(): SwarmConfig {
|
||||
return { ...this.config, specialists: [...this.config.specialists] };
|
||||
}
|
||||
|
||||
updateConfig(updates: Partial<SwarmConfig>): void {
|
||||
this.config = { ...this.config, ...updates };
|
||||
}
|
||||
|
||||
// === Persistence ===
|
||||
|
||||
private loadHistory(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(SWARM_HISTORY_KEY);
|
||||
if (raw) this.history = JSON.parse(raw);
|
||||
} catch {
|
||||
this.history = [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveHistory(): void {
|
||||
try {
|
||||
localStorage.setItem(SWARM_HISTORY_KEY, JSON.stringify(this.history.slice(-25)));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: AgentSwarm | null = null;
|
||||
|
||||
export function getAgentSwarm(config?: Partial<SwarmConfig>): AgentSwarm {
|
||||
if (!_instance) {
|
||||
_instance = new AgentSwarm(config);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetAgentSwarm(): void {
|
||||
_instance = null;
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
/**
|
||||
* OpenFang Team API Client
|
||||
*
|
||||
* REST API client for multi-agent team collaboration endpoints.
|
||||
* Communicates with OpenFang Kernel for team management,
|
||||
* task coordination, and Dev↔QA loops.
|
||||
*
|
||||
* @module lib/team-client
|
||||
*/
|
||||
|
||||
import type {
|
||||
Team,
|
||||
TeamMember,
|
||||
TeamTask,
|
||||
TeamMemberRole,
|
||||
DevQALoop,
|
||||
CreateTeamRequest,
|
||||
AddTeamTaskRequest,
|
||||
TeamResponse,
|
||||
ReviewFeedback,
|
||||
TaskDeliverable,
|
||||
CollaborationEvent,
|
||||
TeamMetrics,
|
||||
} from '../types/team';
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { CollaborationEvent } from '../types/team';
|
||||
|
||||
// === Configuration ===
|
||||
|
||||
const API_BASE = '/api'; // Uses Vite proxy
|
||||
|
||||
// === Error Types ===
|
||||
|
||||
export class TeamAPIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number,
|
||||
public endpoint: string,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TeamAPIError';
|
||||
}
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new TeamAPIError(
|
||||
errorData.message || `HTTP ${response.status}`,
|
||||
response.status,
|
||||
endpoint,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof TeamAPIError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TeamAPIError(
|
||||
(error as Error).message,
|
||||
0,
|
||||
endpoint,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Team API ===
|
||||
|
||||
/**
|
||||
* List all teams
|
||||
*/
|
||||
export async function listTeams(): Promise<{ teams: Team[]; total: number }> {
|
||||
return request<{ teams: Team[]; total: number }>('/teams');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific team by ID
|
||||
*/
|
||||
export async function getTeam(teamId: string): Promise<TeamResponse> {
|
||||
return request<TeamResponse>(`/teams/${teamId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new team
|
||||
*/
|
||||
export async function createTeam(data: CreateTeamRequest): Promise<TeamResponse> {
|
||||
return request<TeamResponse>('/teams', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a team
|
||||
*/
|
||||
export async function updateTeam(
|
||||
teamId: string,
|
||||
data: Partial<Pick<Team, 'name' | 'description' | 'pattern' | 'status'>>
|
||||
): Promise<TeamResponse> {
|
||||
return request<TeamResponse>(`/teams/${teamId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a team
|
||||
*/
|
||||
export async function deleteTeam(teamId: string): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/teams/${teamId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// === Team Member API ===
|
||||
|
||||
/**
|
||||
* Add a member to a team
|
||||
*/
|
||||
export async function addTeamMember(
|
||||
teamId: string,
|
||||
agentId: string,
|
||||
role: TeamMemberRole
|
||||
): Promise<{ member: TeamMember; success: boolean }> {
|
||||
return request<{ member: TeamMember; success: boolean }>(
|
||||
`/teams/${teamId}/members`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agentId, role }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a team
|
||||
*/
|
||||
export async function removeTeamMember(
|
||||
teamId: string,
|
||||
memberId: string
|
||||
): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(
|
||||
`/teams/${teamId}/members/${memberId}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's role
|
||||
*/
|
||||
export async function updateMemberRole(
|
||||
teamId: string,
|
||||
memberId: string,
|
||||
role: TeamMemberRole
|
||||
): Promise<{ member: TeamMember; success: boolean }> {
|
||||
return request<{ member: TeamMember; success: boolean }>(
|
||||
`/teams/${teamId}/members/${memberId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ role }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// === Team Task API ===
|
||||
|
||||
/**
|
||||
* Add a task to a team
|
||||
*/
|
||||
export async function addTeamTask(
|
||||
data: AddTeamTaskRequest
|
||||
): Promise<{ task: TeamTask; success: boolean }> {
|
||||
return request<{ task: TeamTask; success: boolean }>(
|
||||
`/teams/${data.teamId}/tasks`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
priority: data.priority,
|
||||
type: data.type,
|
||||
assigneeId: data.assigneeId,
|
||||
dependencies: data.dependencies,
|
||||
estimate: data.estimate,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a task's status
|
||||
*/
|
||||
export async function updateTaskStatus(
|
||||
teamId: string,
|
||||
taskId: string,
|
||||
status: TeamTask['status']
|
||||
): Promise<{ task: TeamTask; success: boolean }> {
|
||||
return request<{ task: TeamTask; success: boolean }>(
|
||||
`/teams/${teamId}/tasks/${taskId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a task to a member
|
||||
*/
|
||||
export async function assignTask(
|
||||
teamId: string,
|
||||
taskId: string,
|
||||
memberId: string
|
||||
): Promise<{ task: TeamTask; success: boolean }> {
|
||||
return request<{ task: TeamTask; success: boolean }>(
|
||||
`/teams/${teamId}/tasks/${taskId}/assign`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ memberId }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a deliverable for a task
|
||||
*/
|
||||
export async function submitDeliverable(
|
||||
teamId: string,
|
||||
taskId: string,
|
||||
deliverable: TaskDeliverable
|
||||
): Promise<{ task: TeamTask; success: boolean }> {
|
||||
return request<{ task: TeamTask; success: boolean }>(
|
||||
`/teams/${teamId}/tasks/${taskId}/deliverable`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(deliverable),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// === Dev↔QA Loop API ===
|
||||
|
||||
/**
|
||||
* Start a Dev↔QA loop for a task
|
||||
*/
|
||||
export async function startDevQALoop(
|
||||
teamId: string,
|
||||
taskId: string,
|
||||
developerId: string,
|
||||
reviewerId: string
|
||||
): Promise<{ loop: DevQALoop; success: boolean }> {
|
||||
return request<{ loop: DevQALoop; success: boolean }>(
|
||||
`/teams/${teamId}/loops`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ taskId, developerId, reviewerId }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a review for a Dev↔QA loop
|
||||
*/
|
||||
export async function submitReview(
|
||||
teamId: string,
|
||||
loopId: string,
|
||||
feedback: Omit<ReviewFeedback, 'reviewedAt' | 'reviewerId'>
|
||||
): Promise<{ loop: DevQALoop; success: boolean }> {
|
||||
return request<{ loop: DevQALoop; success: boolean }>(
|
||||
`/teams/${teamId}/loops/${loopId}/review`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(feedback),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Dev↔QA loop state
|
||||
*/
|
||||
export async function updateLoopState(
|
||||
teamId: string,
|
||||
loopId: string,
|
||||
state: DevQALoop['state']
|
||||
): Promise<{ loop: DevQALoop; success: boolean }> {
|
||||
return request<{ loop: DevQALoop; success: boolean }>(
|
||||
`/teams/${teamId}/loops/${loopId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ state }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// === Metrics & Events ===
|
||||
|
||||
/**
|
||||
* Get team metrics
|
||||
*/
|
||||
export async function getTeamMetrics(teamId: string): Promise<TeamMetrics> {
|
||||
return request<TeamMetrics>(`/teams/${teamId}/metrics`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent collaboration events
|
||||
*/
|
||||
export async function getTeamEvents(
|
||||
teamId: string,
|
||||
limit?: number
|
||||
): Promise<{ events: CollaborationEvent[]; total: number }> {
|
||||
const query = limit ? `?limit=${limit}` : '';
|
||||
return request<{ events: CollaborationEvent[]; total: number }>(
|
||||
`/teams/${teamId}/events${query}`
|
||||
);
|
||||
}
|
||||
|
||||
// === WebSocket Event Subscription ===
|
||||
|
||||
export type TeamEventType =
|
||||
| 'team.created'
|
||||
| 'team.updated'
|
||||
| 'team.deleted'
|
||||
| 'member.added'
|
||||
| 'member.removed'
|
||||
| 'member.status_changed'
|
||||
| 'task.created'
|
||||
| 'task.assigned'
|
||||
| 'task.status_changed'
|
||||
| 'task.completed'
|
||||
| 'loop.started'
|
||||
| 'loop.state_changed'
|
||||
| 'loop.completed'
|
||||
| 'review.submitted';
|
||||
|
||||
export interface TeamEventMessage {
|
||||
type: 'team_event';
|
||||
eventType: TeamEventType;
|
||||
teamId: string;
|
||||
payload: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to team events via WebSocket
|
||||
* Returns an unsubscribe function
|
||||
*/
|
||||
export function subscribeToTeamEvents(
|
||||
teamId: string | null, // null = all teams
|
||||
callback: (event: TeamEventMessage) => void,
|
||||
ws: WebSocket
|
||||
): () => void {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'team_event') {
|
||||
// Filter by teamId if specified
|
||||
if (teamId === null || data.teamId === teamId) {
|
||||
callback(data as TeamEventMessage);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener('message', handleMessage);
|
||||
|
||||
// Send subscription message
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
topic: teamId ? `team:${teamId}` : 'teams',
|
||||
}));
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
ws.removeEventListener('message', handleMessage);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'unsubscribe',
|
||||
topic: teamId ? `team:${teamId}` : 'teams',
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === Export singleton client ===
|
||||
|
||||
export const teamClient = {
|
||||
// Teams
|
||||
listTeams,
|
||||
getTeam,
|
||||
createTeam,
|
||||
updateTeam,
|
||||
deleteTeam,
|
||||
|
||||
// Members
|
||||
addTeamMember,
|
||||
removeTeamMember,
|
||||
updateMemberRole,
|
||||
|
||||
// Tasks
|
||||
addTeamTask,
|
||||
updateTaskStatus,
|
||||
assignTask,
|
||||
submitDeliverable,
|
||||
|
||||
// Dev↔QA Loops
|
||||
startDevQALoop,
|
||||
submitReview,
|
||||
updateLoopState,
|
||||
|
||||
// Metrics & Events
|
||||
getTeamMetrics,
|
||||
getTeamEvents,
|
||||
subscribeToTeamEvents,
|
||||
};
|
||||
|
||||
export default teamClient;
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* useTeamEvents - WebSocket Real-time Event Sync Hook
|
||||
*
|
||||
* Subscribes to team collaboration events via WebSocket
|
||||
* and updates the team store in real-time.
|
||||
*
|
||||
* @module lib/useTeamEvents
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useTeamStore } from '../store/teamStore';
|
||||
import { useGatewayStore } from '../store/gatewayStore';
|
||||
import type { TeamEventMessage, TeamEventType, CollaborationEvent } from '../lib/team-client';
|
||||
import { silentErrorHandler } from './error-utils';
|
||||
|
||||
interface UseTeamEventsOptions {
|
||||
/** Subscribe to specific team only, or null for all teams */
|
||||
teamId?: string | null;
|
||||
/** Event types to subscribe to (default: all) */
|
||||
eventTypes?: TeamEventType[];
|
||||
/** Maximum events to keep in history (default: 100) */
|
||||
maxEvents?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subscribing to real-time team collaboration events
|
||||
*/
|
||||
export function useTeamEvents(options: UseTeamEventsOptions = {}) {
|
||||
const { teamId = null, eventTypes } = options;
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const {
|
||||
addEvent,
|
||||
updateTaskStatus,
|
||||
updateLoopState,
|
||||
loadTeams,
|
||||
} = useTeamStore();
|
||||
|
||||
const { connectionState } = useGatewayStore();
|
||||
|
||||
const handleTeamEvent = useCallback(
|
||||
(message: TeamEventMessage) => {
|
||||
// Filter by event types if specified
|
||||
if (eventTypes && !eventTypes.includes(message.eventType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create collaboration event for store
|
||||
const event = {
|
||||
type: mapEventType(message.eventType),
|
||||
teamId: message.teamId,
|
||||
sourceAgentId: (message.payload.sourceAgentId as string) || 'system',
|
||||
payload: message.payload,
|
||||
timestamp: message.timestamp,
|
||||
};
|
||||
|
||||
// Add to event history
|
||||
addEvent(event);
|
||||
|
||||
// Handle specific event types
|
||||
switch (message.eventType) {
|
||||
case 'task.status_changed':
|
||||
if (message.payload.taskId && message.payload.status) {
|
||||
updateTaskStatus(
|
||||
message.teamId,
|
||||
message.payload.taskId as string,
|
||||
message.payload.status as any
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'loop.state_changed':
|
||||
if (message.payload.loopId && message.payload.state) {
|
||||
updateLoopState(
|
||||
message.teamId,
|
||||
message.payload.loopId as string,
|
||||
message.payload.state as any
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'team.updated':
|
||||
case 'member.added':
|
||||
case 'member.removed':
|
||||
// Reload teams to get updated data
|
||||
loadTeams().catch(silentErrorHandler('useTeamEvents'));
|
||||
break;
|
||||
}
|
||||
},
|
||||
[eventTypes, addEvent, updateTaskStatus, updateLoopState, loadTeams]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only subscribe when connected
|
||||
if (connectionState !== 'connected') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get WebSocket from gateway client
|
||||
const client = getGatewayClientSafe();
|
||||
if (!client || !client.ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = client.ws;
|
||||
|
||||
// Subscribe to team events
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'team_event') {
|
||||
handleTeamEvent(data as TeamEventMessage);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener('message', handleMessage);
|
||||
|
||||
// Send subscription message
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'subscribe',
|
||||
topic: teamId ? `team:${teamId}` : 'teams',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unsubscribeRef.current = () => {
|
||||
ws.removeEventListener('message', handleMessage);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'unsubscribe',
|
||||
topic: teamId ? `team:${teamId}` : 'teams',
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (unsubscribeRef.current) {
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connectionState, teamId, handleTeamEvent]);
|
||||
|
||||
return {
|
||||
isConnected: connectionState === 'connected',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subscribing to a specific team's events
|
||||
*/
|
||||
export function useTeamEventStream(teamId: string) {
|
||||
return useTeamEvents({ teamId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subscribing to all team events
|
||||
*/
|
||||
export function useAllTeamEvents(options: Omit<UseTeamEventsOptions, 'teamId'> = {}) {
|
||||
return useTeamEvents({ ...options, teamId: null });
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function mapEventType(eventType: TeamEventType): CollaborationEvent['type'] {
|
||||
const mapping: Record<TeamEventType, CollaborationEvent['type']> = {
|
||||
'team.created': 'member_status_change',
|
||||
'team.updated': 'member_status_change',
|
||||
'team.deleted': 'member_status_change',
|
||||
'member.added': 'member_status_change',
|
||||
'member.removed': 'member_status_change',
|
||||
'member.status_changed': 'member_status_change',
|
||||
'task.created': 'task_assigned',
|
||||
'task.assigned': 'task_assigned',
|
||||
'task.status_changed': 'task_started',
|
||||
'task.completed': 'task_completed',
|
||||
'loop.started': 'loop_state_change',
|
||||
'loop.state_changed': 'loop_state_change',
|
||||
'loop.completed': 'loop_state_change',
|
||||
'review.submitted': 'review_submitted',
|
||||
};
|
||||
return mapping[eventType] || 'task_started';
|
||||
}
|
||||
|
||||
function getGatewayClientSafe() {
|
||||
try {
|
||||
// Dynamic import to avoid circular dependency
|
||||
const { getClient } = require('../store/connectionStore');
|
||||
return getClient();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default useTeamEvents;
|
||||
Reference in New Issue
Block a user