feat(team): add OpenFang Team API client and WebSocket events

API Client (lib/team-client.ts):
- Team CRUD: listTeams, getTeam, createTeam, updateTeam, deleteTeam
- Member management: addTeamMember, removeTeamMember, updateMemberRole
- Task management: addTeamTask, updateTaskStatus, assignTask, submitDeliverable
- Dev↔QA loops: startDevQALoop, submitReview, updateLoopState
- Metrics & Events: getTeamMetrics, getTeamEvents, subscribeToTeamEvents
- TeamAPIError class for error handling

WebSocket Events (lib/useTeamEvents.ts):
- useTeamEvents hook for subscribing to team events
- useTeamEventStream hook for specific team events
- useAllTeamEvents hook for all team events
- Real-time task status updates
- Real-time Dev↔QA loop state changes
- Auto-refresh on team/member updates

Event Types:
- team.created/updated/deleted
- member.added/removed/status_changed
- task.created/assigned/status_changed/completed
- loop.started/state_changed/completed
- review.submitted

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-15 03:38:36 +08:00
parent 46cbe2b50e
commit 4802eb7d6a
3 changed files with 644 additions and 3 deletions

View File

@@ -0,0 +1,437 @@
/**
* 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';
// === 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;

View File

@@ -0,0 +1,202 @@
/**
* 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 } from '../lib/team-client';
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, maxEvents = 100 } = options;
const unsubscribeRef = useRef<(() => void) | null>(null);
const {
addEvent,
setActiveTeam,
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(() => {});
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 { getGatewayClient } = require('../lib/gateway-client');
return getGatewayClient();
} catch {
return null;
}
}
export default useTeamEvents;