UNPKG

recoder-shared

Version:

Shared types, utilities, and configurations for Recoder

502 lines (411 loc) 13.9 kB
/** * Collaborative Session Manager * KILLER FEATURE: Multi-developer + AI agent session management with persistence * Cursor doesn't have session management - we do! */ import { EventEmitter } from 'events'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from '../utils/logger'; import { CollaborationSession, CollaborationUser, AISuggestion, CodeChange } from './websocket-server'; export interface SessionPersistence { saveSession(session: CollaborationSession): Promise<void>; loadSession(sessionId: string): Promise<CollaborationSession | null>; deleteSession(sessionId: string): Promise<void>; listSessions(userId?: string): Promise<CollaborationSession[]>; saveCodeHistory(sessionId: string, change: CodeChange): Promise<void>; getCodeHistory(sessionId: string, file?: string): Promise<CodeChange[]>; } export interface UserPresence { userId: string; sessionId: string; status: 'active' | 'idle' | 'away' | 'coding' | 'reviewing'; lastActivity: Date; currentFile?: string; cursor?: { line: number; column: number; file: string; }; isTyping?: boolean; typingIn?: string; // file path } export interface SessionInvite { id: string; sessionId: string; invitedBy: string; invitedUser: string; invitedEmail?: string; message?: string; expiresAt: Date; accepted?: boolean; acceptedAt?: Date; } export interface SessionAnalytics { sessionId: string; totalUsers: number; totalTime: number; // seconds codeChanges: number; aiSuggestions: number; aiAcceptanceRate: number; mostActiveUser: string; mostEditedFiles: string[]; collaborationScore: number; // 0-100, based on interaction quality } export class SessionManager extends EventEmitter { private sessions: Map<string, CollaborationSession> = new Map(); private userPresence: Map<string, UserPresence> = new Map(); private sessionInvites: Map<string, SessionInvite> = new Map(); private analytics: Map<string, SessionAnalytics> = new Map(); private persistence: SessionPersistence | null = null; private presenceUpdateInterval: NodeJS.Timeout | null = null; constructor(persistence?: SessionPersistence) { super(); this.persistence = persistence || null; this.startPresenceUpdates(); } // Session Management async createSession( creator: CollaborationUser, sessionData: Partial<CollaborationSession> ): Promise<CollaborationSession> { const sessionId = uuidv4(); const session: CollaborationSession = { id: sessionId, name: sessionData.name || `${creator.name}'s Session`, projectPath: sessionData.projectPath || '', createdAt: new Date(), createdBy: creator.id, users: new Map([[creator.id, creator]]), aiAgents: new Map(), settings: { allowAIAgents: true, maxUsers: 10, codeReviewMode: false, voiceChatEnabled: false, autoSave: true, ...sessionData.settings }, metadata: { language: 'typescript', description: '', tags: [], ...sessionData.metadata } }; this.sessions.set(sessionId, session); // Initialize analytics this.analytics.set(sessionId, { sessionId, totalUsers: 1, totalTime: 0, codeChanges: 0, aiSuggestions: 0, aiAcceptanceRate: 0, mostActiveUser: creator.id, mostEditedFiles: [], collaborationScore: 0 }); // Initialize user presence this.updateUserPresence(creator.id, sessionId, { status: 'active', lastActivity: new Date() }); // Persist session if (this.persistence) { await this.persistence.saveSession(session); } this.emit('session-created', { session, creator }); Logger.info(`Session created: ${sessionId} by ${creator.name}`); return session; } async joinSession(sessionId: string, user: CollaborationUser): Promise<boolean> { const session = this.sessions.get(sessionId); if (!session) { // Try to load from persistence if (this.persistence) { const persistedSession = await this.persistence.loadSession(sessionId); if (persistedSession) { this.sessions.set(sessionId, persistedSession); return this.joinSession(sessionId, user); } } throw new Error('Session not found'); } // Check capacity if (session.users.size >= session.settings.maxUsers) { throw new Error('Session is full'); } // Check if user is already in session if (session.users.has(user.id)) { Logger.warn(`User ${user.id} already in session ${sessionId}`); return true; } // Add user to session session.users.set(user.id, { ...user, isActive: true, lastSeen: new Date() }); // Update analytics const analytics = this.analytics.get(sessionId); if (analytics) { analytics.totalUsers = Math.max(analytics.totalUsers, session.users.size); } // Update user presence this.updateUserPresence(user.id, sessionId, { status: 'active', lastActivity: new Date() }); // Persist session if (this.persistence) { await this.persistence.saveSession(session); } this.emit('user-joined-session', { sessionId, user, session }); Logger.info(`User ${user.name} joined session ${sessionId}`); return true; } async leaveSession(sessionId: string, userId: string): Promise<boolean> { const session = this.sessions.get(sessionId); if (!session) { return false; } const user = session.users.get(userId); if (!user) { return false; } // Remove user from session session.users.delete(userId); this.userPresence.delete(userId); // Clean up empty sessions if (session.users.size === 0) { this.sessions.delete(sessionId); this.analytics.delete(sessionId); if (this.persistence) { await this.persistence.deleteSession(sessionId); } this.emit('session-ended', { sessionId, session }); Logger.info(`Empty session ${sessionId} cleaned up`); } else { // Persist updated session if (this.persistence) { await this.persistence.saveSession(session); } } this.emit('user-left-session', { sessionId, userId, user, session }); Logger.info(`User ${user.name} left session ${sessionId}`); return true; } // User Presence Management updateUserPresence( userId: string, sessionId: string, updates: Partial<UserPresence> ): void { const currentPresence = this.userPresence.get(userId); const presence: UserPresence = { userId, sessionId, status: 'active', lastActivity: new Date(), ...currentPresence, ...updates }; this.userPresence.set(userId, presence); // Update user in session const session = this.sessions.get(sessionId); if (session && session.users.has(userId)) { const user = session.users.get(userId)!; user.isActive = presence.status !== 'away'; user.lastSeen = presence.lastActivity; if (presence.cursor) { user.cursor = presence.cursor; } } this.emit('presence-updated', { userId, sessionId, presence }); } getUserPresence(userId: string): UserPresence | null { return this.userPresence.get(userId) || null; } getSessionPresence(sessionId: string): UserPresence[] { return Array.from(this.userPresence.values()) .filter(presence => presence.sessionId === sessionId); } // Session Invitations async createInvite( sessionId: string, invitedBy: string, invitedUser: string, options?: { email?: string; message?: string; expiresIn?: number; // hours } ): Promise<SessionInvite> { const session = this.sessions.get(sessionId); if (!session) { throw new Error('Session not found'); } // Check if inviter is in session if (!session.users.has(invitedBy)) { throw new Error('Only session members can invite others'); } const inviteId = uuidv4(); const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + (options?.expiresIn || 24)); const invite: SessionInvite = { id: inviteId, sessionId, invitedBy, invitedUser, invitedEmail: options?.email, message: options?.message, expiresAt, accepted: false }; this.sessionInvites.set(inviteId, invite); this.emit('invite-created', { invite, session }); Logger.info(`Invite created: ${inviteId} for session ${sessionId}`); return invite; } async acceptInvite(inviteId: string, user: CollaborationUser): Promise<boolean> { const invite = this.sessionInvites.get(inviteId); if (!invite) { throw new Error('Invite not found'); } if (invite.expiresAt < new Date()) { this.sessionInvites.delete(inviteId); throw new Error('Invite has expired'); } if (invite.accepted) { throw new Error('Invite already accepted'); } // Validate user (if email specified) if (invite.invitedEmail && user.email !== invite.invitedEmail) { throw new Error('Invite is for a different email address'); } // Join the session await this.joinSession(invite.sessionId, user); // Mark invite as accepted invite.accepted = true; invite.acceptedAt = new Date(); this.emit('invite-accepted', { invite, user }); Logger.info(`Invite ${inviteId} accepted by ${user.name}`); return true; } // Analytics and Monitoring recordCodeChange(sessionId: string, change: CodeChange): void { const analytics = this.analytics.get(sessionId); if (!analytics) return; analytics.codeChanges++; // Track most edited files if (!analytics.mostEditedFiles.includes(change.file)) { analytics.mostEditedFiles.push(change.file); } // Update collaboration score based on change patterns this.updateCollaborationScore(sessionId, change); // Persist code change if (this.persistence) { this.persistence.saveCodeHistory(sessionId, change).catch(err => Logger.error('Failed to save code history', err) ); } } recordAISuggestion(sessionId: string, suggestion: AISuggestion): void { const analytics = this.analytics.get(sessionId); if (!analytics) return; analytics.aiSuggestions++; if (suggestion.accepted) { const acceptanceRate = analytics.aiSuggestions > 0 ? (analytics.aiSuggestions / (analytics.aiSuggestions + 1)) : 1; analytics.aiAcceptanceRate = acceptanceRate; } } getSessionAnalytics(sessionId: string): SessionAnalytics | null { return this.analytics.get(sessionId) || null; } // Private Methods private startPresenceUpdates(): void { // Update presence every 30 seconds this.presenceUpdateInterval = setInterval(() => { this.cleanupInactiveUsers(); }, 30000); } private cleanupInactiveUsers(): void { const now = new Date(); const inactiveThreshold = 5 * 60 * 1000; // 5 minutes for (const [userId, presence] of this.userPresence.entries()) { const timeSinceActivity = now.getTime() - presence.lastActivity.getTime(); if (timeSinceActivity > inactiveThreshold && presence.status !== 'away') { this.updateUserPresence(userId, presence.sessionId, { status: 'away' }); } } // Clean up expired invites for (const [inviteId, invite] of this.sessionInvites.entries()) { if (invite.expiresAt < now) { this.sessionInvites.delete(inviteId); Logger.info(`Expired invite cleaned up: ${inviteId}`); } } } private updateCollaborationScore(sessionId: string, change: CodeChange): void { const analytics = this.analytics.get(sessionId); const session = this.sessions.get(sessionId); if (!analytics || !session) return; // Simple collaboration scoring based on: // - Number of active users // - Code change frequency // - File diversity const activeUsers = Array.from(session.users.values()) .filter(user => user.isActive).length; const filesDiversity = analytics.mostEditedFiles.length; const changeFrequency = analytics.codeChanges / ((Date.now() - session.createdAt.getTime()) / 1000 / 60); // changes per minute analytics.collaborationScore = Math.min(100, Math.round( (activeUsers * 20) + (filesDiversity * 10) + (changeFrequency * 5) )); } // Public API Methods getSession(sessionId: string): CollaborationSession | null { return this.sessions.get(sessionId) || null; } getAllSessions(): CollaborationSession[] { return Array.from(this.sessions.values()); } getUserSessions(userId: string): CollaborationSession[] { return Array.from(this.sessions.values()) .filter(session => session.users.has(userId)); } getActiveUsers(sessionId: string): CollaborationUser[] { const session = this.sessions.get(sessionId); if (!session) return []; return Array.from(session.users.values()) .filter(user => user.isActive); } async shutdown(): Promise<void> { Logger.info('Shutting down session manager...'); if (this.presenceUpdateInterval) { clearInterval(this.presenceUpdateInterval); } // Save all sessions if persistence is enabled if (this.persistence) { const savePromises = Array.from(this.sessions.values()) .map(session => this.persistence!.saveSession(session)); await Promise.all(savePromises); } this.sessions.clear(); this.userPresence.clear(); this.sessionInvites.clear(); this.analytics.clear(); Logger.info('Session manager shut down'); } } // Types are already exported as interfaces above