elsabro 2.3.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +668 -20
- package/bin/install.js +0 -0
- package/flows/development-flow.json +452 -0
- package/flows/quick-flow.json +118 -0
- package/package.json +3 -2
- package/references/SYSTEM_INDEX.md +379 -5
- package/references/agent-marketplace.md +2274 -0
- package/references/agent-protocol.md +1126 -0
- package/references/ai-code-suggestions.md +2413 -0
- package/references/checkpointing.md +595 -0
- package/references/collaboration-patterns.md +851 -0
- package/references/collaborative-sessions.md +1081 -0
- package/references/configuration-management.md +1810 -0
- package/references/cost-tracking.md +1095 -0
- package/references/enterprise-sso.md +2001 -0
- package/references/error-contracts-v2.md +968 -0
- package/references/event-driven.md +1031 -0
- package/references/flow-orchestration.md +940 -0
- package/references/flow-visualization.md +1557 -0
- package/references/ide-integrations.md +3513 -0
- package/references/interrupt-system.md +681 -0
- package/references/kubernetes-deployment.md +3099 -0
- package/references/memory-system.md +683 -0
- package/references/mobile-companion.md +3236 -0
- package/references/multi-llm-providers.md +2494 -0
- package/references/multi-project-memory.md +1182 -0
- package/references/observability.md +793 -0
- package/references/output-schemas.md +858 -0
- package/references/performance-profiler.md +955 -0
- package/references/plugin-system.md +1526 -0
- package/references/prompt-management.md +292 -0
- package/references/sandbox-execution.md +303 -0
- package/references/security-system.md +1253 -0
- package/references/streaming.md +696 -0
- package/references/testing-framework.md +1151 -0
- package/references/time-travel.md +802 -0
- package/references/tool-registry.md +886 -0
- package/references/voice-commands.md +3296 -0
- package/templates/agent-marketplace-config.json +220 -0
- package/templates/agent-protocol-config.json +136 -0
- package/templates/ai-suggestions-config.json +100 -0
- package/templates/checkpoint-state.json +61 -0
- package/templates/collaboration-config.json +157 -0
- package/templates/collaborative-sessions-config.json +153 -0
- package/templates/configuration-config.json +245 -0
- package/templates/cost-tracking-config.json +148 -0
- package/templates/enterprise-sso-config.json +438 -0
- package/templates/events-config.json +148 -0
- package/templates/flow-visualization-config.json +196 -0
- package/templates/ide-integrations-config.json +442 -0
- package/templates/kubernetes-config.json +764 -0
- package/templates/memory-state.json +84 -0
- package/templates/mobile-companion-config.json +600 -0
- package/templates/multi-llm-config.json +544 -0
- package/templates/multi-project-memory-config.json +145 -0
- package/templates/observability-config.json +109 -0
- package/templates/performance-profiler-config.json +125 -0
- package/templates/plugin-config.json +170 -0
- package/templates/prompt-management-config.json +86 -0
- package/templates/sandbox-config.json +185 -0
- package/templates/schemas-config.json +65 -0
- package/templates/security-config.json +120 -0
- package/templates/streaming-config.json +72 -0
- package/templates/testing-config.json +81 -0
- package/templates/timetravel-config.json +62 -0
- package/templates/tool-registry-config.json +109 -0
- package/templates/voice-commands-config.json +658 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
# ELSABRO Collaborative Sessions System
|
|
2
|
+
|
|
3
|
+
> Sistema de sesiones colaborativas en tiempo real para múltiples usuarios y agentes.
|
|
4
|
+
|
|
5
|
+
## Arquitectura General
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ Collaborative Sessions System │
|
|
10
|
+
├─────────────────────────────────────────────────────────────────────────┤
|
|
11
|
+
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
|
12
|
+
│ │ SessionManager │ │ RealtimeSync │ │ConflictResolver │ │
|
|
13
|
+
│ │ ───────────── │ │ ───────────── │ │ ───────────── │ │
|
|
14
|
+
│ │ • Create/Join │ │ • WebSocket │ │ • OT/CRDT │ │
|
|
15
|
+
│ │ • Permissions │ │ • Broadcasting │ │ • Auto-merge │ │
|
|
16
|
+
│ │ • History │ │ • Reconnection │ │ • Manual resolve│ │
|
|
17
|
+
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
|
18
|
+
│ │ │
|
|
19
|
+
│ ┌───────────────────────────┴───────────────────────────────┐ │
|
|
20
|
+
│ │ PresenceTracker │ │
|
|
21
|
+
│ │ • Online users • Cursors • Activity • Typing indicators │ │
|
|
22
|
+
│ └────────────────────────────────────────────────────────────┘ │
|
|
23
|
+
│ │ │
|
|
24
|
+
│ ┌───────────────────────────┴───────────────────────────────┐ │
|
|
25
|
+
│ │ CollaborativeState │ │
|
|
26
|
+
│ │ • Shared state • Atomic updates • Event sourcing │ │
|
|
27
|
+
│ └────────────────────────────────────────────────────────────┘ │
|
|
28
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 1. SessionManager
|
|
34
|
+
|
|
35
|
+
### Propósito
|
|
36
|
+
Gestiona el ciclo de vida de sesiones colaborativas.
|
|
37
|
+
|
|
38
|
+
### Interfaz
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
interface CollaborativeSession {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
type: 'flow' | 'planning' | 'review' | 'debug';
|
|
45
|
+
host: Participant;
|
|
46
|
+
participants: Participant[];
|
|
47
|
+
state: SessionState;
|
|
48
|
+
permissions: SessionPermissions;
|
|
49
|
+
settings: SessionSettings;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
expiresAt?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Participant {
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
type: 'user' | 'agent';
|
|
58
|
+
role: 'host' | 'editor' | 'viewer';
|
|
59
|
+
color: string; // For presence indicators
|
|
60
|
+
cursor?: CursorPosition;
|
|
61
|
+
status: 'online' | 'away' | 'offline';
|
|
62
|
+
joinedAt: string;
|
|
63
|
+
lastActivity: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface SessionState {
|
|
67
|
+
version: number;
|
|
68
|
+
data: Record<string, unknown>;
|
|
69
|
+
locks: Map<string, string>; // path -> participantId
|
|
70
|
+
history: StateChange[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface SessionPermissions {
|
|
74
|
+
canEdit: string[]; // Role or participant IDs
|
|
75
|
+
canInvite: string[];
|
|
76
|
+
canKick: string[];
|
|
77
|
+
canLock: string[];
|
|
78
|
+
maxParticipants: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface SessionSettings {
|
|
82
|
+
autoSave: boolean;
|
|
83
|
+
saveInterval: number;
|
|
84
|
+
conflictResolution: 'automatic' | 'manual' | 'last-write-wins';
|
|
85
|
+
allowAnonymous: boolean;
|
|
86
|
+
requireApproval: boolean;
|
|
87
|
+
idleTimeout: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface SessionManager {
|
|
91
|
+
// Session lifecycle
|
|
92
|
+
create(config: CreateSessionConfig): Promise<CollaborativeSession>;
|
|
93
|
+
join(sessionId: string, participant: Omit<Participant, 'joinedAt' | 'lastActivity'>): Promise<CollaborativeSession>;
|
|
94
|
+
leave(sessionId: string, participantId: string): Promise<void>;
|
|
95
|
+
close(sessionId: string): Promise<void>;
|
|
96
|
+
|
|
97
|
+
// Session queries
|
|
98
|
+
get(sessionId: string): Promise<CollaborativeSession | null>;
|
|
99
|
+
list(filter?: SessionFilter): Promise<CollaborativeSession[]>;
|
|
100
|
+
findByParticipant(participantId: string): Promise<CollaborativeSession[]>;
|
|
101
|
+
|
|
102
|
+
// Invitations
|
|
103
|
+
invite(sessionId: string, email: string, role: Participant['role']): Promise<Invitation>;
|
|
104
|
+
acceptInvitation(invitationId: string, participant: Participant): Promise<CollaborativeSession>;
|
|
105
|
+
revokeInvitation(invitationId: string): Promise<void>;
|
|
106
|
+
|
|
107
|
+
// Permissions
|
|
108
|
+
updatePermissions(sessionId: string, permissions: Partial<SessionPermissions>): Promise<void>;
|
|
109
|
+
updateParticipantRole(sessionId: string, participantId: string, role: Participant['role']): Promise<void>;
|
|
110
|
+
kickParticipant(sessionId: string, participantId: string, reason?: string): Promise<void>;
|
|
111
|
+
|
|
112
|
+
// Events
|
|
113
|
+
on(event: SessionEvent, callback: Function): () => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type SessionEvent =
|
|
117
|
+
| 'session:created'
|
|
118
|
+
| 'session:closed'
|
|
119
|
+
| 'participant:joined'
|
|
120
|
+
| 'participant:left'
|
|
121
|
+
| 'participant:kicked'
|
|
122
|
+
| 'state:changed'
|
|
123
|
+
| 'conflict:detected'
|
|
124
|
+
| 'error';
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Implementación
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
class SessionManagerImpl implements SessionManager {
|
|
131
|
+
private sessions: Map<string, CollaborativeSession> = new Map();
|
|
132
|
+
private invitations: Map<string, Invitation> = new Map();
|
|
133
|
+
private eventEmitter: EventEmitter = new EventEmitter();
|
|
134
|
+
|
|
135
|
+
constructor(
|
|
136
|
+
private sync: RealtimeSync,
|
|
137
|
+
private presence: PresenceTracker,
|
|
138
|
+
private config: SessionManagerConfig
|
|
139
|
+
) {}
|
|
140
|
+
|
|
141
|
+
async create(config: CreateSessionConfig): Promise<CollaborativeSession> {
|
|
142
|
+
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
143
|
+
|
|
144
|
+
const session: CollaborativeSession = {
|
|
145
|
+
id: sessionId,
|
|
146
|
+
name: config.name,
|
|
147
|
+
type: config.type,
|
|
148
|
+
host: config.host,
|
|
149
|
+
participants: [config.host],
|
|
150
|
+
state: {
|
|
151
|
+
version: 0,
|
|
152
|
+
data: config.initialData || {},
|
|
153
|
+
locks: new Map(),
|
|
154
|
+
history: []
|
|
155
|
+
},
|
|
156
|
+
permissions: config.permissions || this.getDefaultPermissions(),
|
|
157
|
+
settings: { ...this.config.defaultSettings, ...config.settings },
|
|
158
|
+
createdAt: new Date().toISOString()
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (config.expiresIn) {
|
|
162
|
+
session.expiresAt = new Date(Date.now() + config.expiresIn).toISOString();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.sessions.set(sessionId, session);
|
|
166
|
+
|
|
167
|
+
// Initialize realtime sync
|
|
168
|
+
await this.sync.initSession(sessionId);
|
|
169
|
+
|
|
170
|
+
// Track host presence
|
|
171
|
+
this.presence.track(sessionId, config.host.id);
|
|
172
|
+
|
|
173
|
+
this.eventEmitter.emit('session:created', session);
|
|
174
|
+
return session;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async join(
|
|
178
|
+
sessionId: string,
|
|
179
|
+
participant: Omit<Participant, 'joinedAt' | 'lastActivity'>
|
|
180
|
+
): Promise<CollaborativeSession> {
|
|
181
|
+
const session = this.sessions.get(sessionId);
|
|
182
|
+
if (!session) {
|
|
183
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check max participants
|
|
187
|
+
if (session.participants.length >= session.permissions.maxParticipants) {
|
|
188
|
+
throw new Error('Session is full');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Check if requires approval
|
|
192
|
+
if (session.settings.requireApproval) {
|
|
193
|
+
await this.requestApproval(session, participant);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const fullParticipant: Participant = {
|
|
197
|
+
...participant,
|
|
198
|
+
color: this.assignColor(session),
|
|
199
|
+
status: 'online',
|
|
200
|
+
joinedAt: new Date().toISOString(),
|
|
201
|
+
lastActivity: new Date().toISOString()
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
session.participants.push(fullParticipant);
|
|
205
|
+
|
|
206
|
+
// Subscribe to realtime updates
|
|
207
|
+
await this.sync.subscribe(sessionId, participant.id);
|
|
208
|
+
|
|
209
|
+
// Track presence
|
|
210
|
+
this.presence.track(sessionId, participant.id);
|
|
211
|
+
|
|
212
|
+
this.eventEmitter.emit('participant:joined', {
|
|
213
|
+
session,
|
|
214
|
+
participant: fullParticipant
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Broadcast to other participants
|
|
218
|
+
await this.sync.broadcast(sessionId, {
|
|
219
|
+
type: 'participant:joined',
|
|
220
|
+
participant: fullParticipant
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return session;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async leave(sessionId: string, participantId: string): Promise<void> {
|
|
227
|
+
const session = this.sessions.get(sessionId);
|
|
228
|
+
if (!session) return;
|
|
229
|
+
|
|
230
|
+
const participantIndex = session.participants.findIndex(p => p.id === participantId);
|
|
231
|
+
if (participantIndex === -1) return;
|
|
232
|
+
|
|
233
|
+
const participant = session.participants[participantIndex];
|
|
234
|
+
session.participants.splice(participantIndex, 1);
|
|
235
|
+
|
|
236
|
+
// Release any locks held by this participant
|
|
237
|
+
for (const [path, lockHolder] of session.state.locks) {
|
|
238
|
+
if (lockHolder === participantId) {
|
|
239
|
+
session.state.locks.delete(path);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Unsubscribe from updates
|
|
244
|
+
await this.sync.unsubscribe(sessionId, participantId);
|
|
245
|
+
|
|
246
|
+
// Stop presence tracking
|
|
247
|
+
this.presence.untrack(sessionId, participantId);
|
|
248
|
+
|
|
249
|
+
this.eventEmitter.emit('participant:left', { session, participant });
|
|
250
|
+
|
|
251
|
+
// Broadcast to remaining participants
|
|
252
|
+
await this.sync.broadcast(sessionId, {
|
|
253
|
+
type: 'participant:left',
|
|
254
|
+
participantId
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// If host left, transfer or close
|
|
258
|
+
if (participant.role === 'host') {
|
|
259
|
+
await this.handleHostLeave(session);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async close(sessionId: string): Promise<void> {
|
|
264
|
+
const session = this.sessions.get(sessionId);
|
|
265
|
+
if (!session) return;
|
|
266
|
+
|
|
267
|
+
// Notify all participants
|
|
268
|
+
await this.sync.broadcast(sessionId, {
|
|
269
|
+
type: 'session:closing',
|
|
270
|
+
reason: 'Host closed the session'
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Clean up all participants
|
|
274
|
+
for (const participant of session.participants) {
|
|
275
|
+
await this.leave(sessionId, participant.id);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Destroy sync channel
|
|
279
|
+
await this.sync.destroySession(sessionId);
|
|
280
|
+
|
|
281
|
+
this.sessions.delete(sessionId);
|
|
282
|
+
this.eventEmitter.emit('session:closed', session);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async handleHostLeave(session: CollaborativeSession): Promise<void> {
|
|
286
|
+
// Find next editor to promote
|
|
287
|
+
const newHost = session.participants.find(p => p.role === 'editor');
|
|
288
|
+
|
|
289
|
+
if (newHost) {
|
|
290
|
+
newHost.role = 'host';
|
|
291
|
+
session.host = newHost;
|
|
292
|
+
|
|
293
|
+
await this.sync.broadcast(session.id, {
|
|
294
|
+
type: 'host:transferred',
|
|
295
|
+
newHostId: newHost.id
|
|
296
|
+
});
|
|
297
|
+
} else {
|
|
298
|
+
// No editors, close session
|
|
299
|
+
await this.close(session.id);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private assignColor(session: CollaborativeSession): string {
|
|
304
|
+
const colors = [
|
|
305
|
+
'#ef4444', '#f97316', '#f59e0b', '#84cc16',
|
|
306
|
+
'#22c55e', '#14b8a6', '#06b6d4', '#3b82f6',
|
|
307
|
+
'#6366f1', '#8b5cf6', '#a855f7', '#ec4899'
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
const usedColors = new Set(session.participants.map(p => p.color));
|
|
311
|
+
return colors.find(c => !usedColors.has(c)) || colors[0];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private getDefaultPermissions(): SessionPermissions {
|
|
315
|
+
return {
|
|
316
|
+
canEdit: ['host', 'editor'],
|
|
317
|
+
canInvite: ['host'],
|
|
318
|
+
canKick: ['host'],
|
|
319
|
+
canLock: ['host', 'editor'],
|
|
320
|
+
maxParticipants: 10
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
on(event: SessionEvent, callback: Function): () => void {
|
|
325
|
+
this.eventEmitter.on(event, callback);
|
|
326
|
+
return () => this.eventEmitter.off(event, callback);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## 2. RealtimeSync
|
|
334
|
+
|
|
335
|
+
### Propósito
|
|
336
|
+
Sincronización en tiempo real usando WebSocket con reconexión automática.
|
|
337
|
+
|
|
338
|
+
### Interfaz
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
interface SyncMessage {
|
|
342
|
+
type: string;
|
|
343
|
+
sessionId: string;
|
|
344
|
+
senderId: string;
|
|
345
|
+
timestamp: string;
|
|
346
|
+
payload: unknown;
|
|
347
|
+
version?: number;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface RealtimeSync {
|
|
351
|
+
// Connection
|
|
352
|
+
connect(): Promise<void>;
|
|
353
|
+
disconnect(): Promise<void>;
|
|
354
|
+
isConnected(): boolean;
|
|
355
|
+
|
|
356
|
+
// Session management
|
|
357
|
+
initSession(sessionId: string): Promise<void>;
|
|
358
|
+
destroySession(sessionId: string): Promise<void>;
|
|
359
|
+
subscribe(sessionId: string, participantId: string): Promise<void>;
|
|
360
|
+
unsubscribe(sessionId: string, participantId: string): Promise<void>;
|
|
361
|
+
|
|
362
|
+
// Messaging
|
|
363
|
+
broadcast(sessionId: string, message: Omit<SyncMessage, 'sessionId' | 'timestamp'>): Promise<void>;
|
|
364
|
+
send(sessionId: string, targetId: string, message: Omit<SyncMessage, 'sessionId' | 'timestamp'>): Promise<void>;
|
|
365
|
+
|
|
366
|
+
// State sync
|
|
367
|
+
pushState(sessionId: string, path: string, value: unknown): Promise<void>;
|
|
368
|
+
pullState(sessionId: string): Promise<Record<string, unknown>>;
|
|
369
|
+
|
|
370
|
+
// Events
|
|
371
|
+
onMessage(callback: (message: SyncMessage) => void): () => void;
|
|
372
|
+
onConnectionChange(callback: (connected: boolean) => void): () => void;
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Implementación
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
class RealtimeSyncImpl implements RealtimeSync {
|
|
380
|
+
private ws: WebSocket | null = null;
|
|
381
|
+
private subscriptions: Map<string, Set<string>> = new Map();
|
|
382
|
+
private messageQueue: SyncMessage[] = [];
|
|
383
|
+
private reconnectAttempts: number = 0;
|
|
384
|
+
private messageCallbacks: Set<(message: SyncMessage) => void> = new Set();
|
|
385
|
+
private connectionCallbacks: Set<(connected: boolean) => void> = new Set();
|
|
386
|
+
|
|
387
|
+
constructor(private config: RealtimeSyncConfig) {}
|
|
388
|
+
|
|
389
|
+
async connect(): Promise<void> {
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
try {
|
|
392
|
+
this.ws = new WebSocket(this.config.serverUrl);
|
|
393
|
+
|
|
394
|
+
this.ws.onopen = () => {
|
|
395
|
+
this.reconnectAttempts = 0;
|
|
396
|
+
this.flushMessageQueue();
|
|
397
|
+
this.notifyConnectionChange(true);
|
|
398
|
+
resolve();
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
this.ws.onmessage = (event) => {
|
|
402
|
+
const message = JSON.parse(event.data) as SyncMessage;
|
|
403
|
+
this.handleMessage(message);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
this.ws.onclose = () => {
|
|
407
|
+
this.notifyConnectionChange(false);
|
|
408
|
+
this.scheduleReconnect();
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
this.ws.onerror = (error) => {
|
|
412
|
+
console.error('WebSocket error:', error);
|
|
413
|
+
reject(error);
|
|
414
|
+
};
|
|
415
|
+
} catch (error) {
|
|
416
|
+
reject(error);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async disconnect(): Promise<void> {
|
|
422
|
+
if (this.ws) {
|
|
423
|
+
this.ws.close();
|
|
424
|
+
this.ws = null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
isConnected(): boolean {
|
|
429
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private scheduleReconnect(): void {
|
|
433
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
434
|
+
console.error('Max reconnection attempts reached');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const delay = Math.min(
|
|
439
|
+
this.config.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts),
|
|
440
|
+
this.config.reconnectMaxDelay
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
this.reconnectAttempts++;
|
|
444
|
+
|
|
445
|
+
setTimeout(() => {
|
|
446
|
+
this.connect().catch(console.error);
|
|
447
|
+
}, delay);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async broadcast(
|
|
451
|
+
sessionId: string,
|
|
452
|
+
message: Omit<SyncMessage, 'sessionId' | 'timestamp'>
|
|
453
|
+
): Promise<void> {
|
|
454
|
+
const fullMessage: SyncMessage = {
|
|
455
|
+
...message,
|
|
456
|
+
sessionId,
|
|
457
|
+
timestamp: new Date().toISOString()
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
if (this.isConnected()) {
|
|
461
|
+
this.ws!.send(JSON.stringify({
|
|
462
|
+
action: 'broadcast',
|
|
463
|
+
...fullMessage
|
|
464
|
+
}));
|
|
465
|
+
} else {
|
|
466
|
+
this.messageQueue.push(fullMessage);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async send(
|
|
471
|
+
sessionId: string,
|
|
472
|
+
targetId: string,
|
|
473
|
+
message: Omit<SyncMessage, 'sessionId' | 'timestamp'>
|
|
474
|
+
): Promise<void> {
|
|
475
|
+
const fullMessage: SyncMessage = {
|
|
476
|
+
...message,
|
|
477
|
+
sessionId,
|
|
478
|
+
timestamp: new Date().toISOString()
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
if (this.isConnected()) {
|
|
482
|
+
this.ws!.send(JSON.stringify({
|
|
483
|
+
action: 'direct',
|
|
484
|
+
targetId,
|
|
485
|
+
...fullMessage
|
|
486
|
+
}));
|
|
487
|
+
} else {
|
|
488
|
+
this.messageQueue.push(fullMessage);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async pushState(sessionId: string, path: string, value: unknown): Promise<void> {
|
|
493
|
+
await this.broadcast(sessionId, {
|
|
494
|
+
type: 'state:update',
|
|
495
|
+
senderId: 'system',
|
|
496
|
+
payload: { path, value }
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async pullState(sessionId: string): Promise<Record<string, unknown>> {
|
|
501
|
+
return new Promise((resolve) => {
|
|
502
|
+
const callback = (message: SyncMessage) => {
|
|
503
|
+
if (message.type === 'state:full' && message.sessionId === sessionId) {
|
|
504
|
+
this.messageCallbacks.delete(callback);
|
|
505
|
+
resolve(message.payload as Record<string, unknown>);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
this.messageCallbacks.add(callback);
|
|
510
|
+
|
|
511
|
+
this.ws?.send(JSON.stringify({
|
|
512
|
+
action: 'pull_state',
|
|
513
|
+
sessionId
|
|
514
|
+
}));
|
|
515
|
+
|
|
516
|
+
// Timeout fallback
|
|
517
|
+
setTimeout(() => {
|
|
518
|
+
this.messageCallbacks.delete(callback);
|
|
519
|
+
resolve({});
|
|
520
|
+
}, 5000);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private handleMessage(message: SyncMessage): void {
|
|
525
|
+
this.messageCallbacks.forEach(cb => cb(message));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private flushMessageQueue(): void {
|
|
529
|
+
while (this.messageQueue.length > 0 && this.isConnected()) {
|
|
530
|
+
const message = this.messageQueue.shift()!;
|
|
531
|
+
this.ws!.send(JSON.stringify({
|
|
532
|
+
action: 'broadcast',
|
|
533
|
+
...message
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private notifyConnectionChange(connected: boolean): void {
|
|
539
|
+
this.connectionCallbacks.forEach(cb => cb(connected));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
onMessage(callback: (message: SyncMessage) => void): () => void {
|
|
543
|
+
this.messageCallbacks.add(callback);
|
|
544
|
+
return () => this.messageCallbacks.delete(callback);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
onConnectionChange(callback: (connected: boolean) => void): () => void {
|
|
548
|
+
this.connectionCallbacks.add(callback);
|
|
549
|
+
return () => this.connectionCallbacks.delete(callback);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
556
|
+
## 3. ConflictResolver
|
|
557
|
+
|
|
558
|
+
### Propósito
|
|
559
|
+
Resuelve conflictos de edición concurrente usando OT (Operational Transformation) o CRDT.
|
|
560
|
+
|
|
561
|
+
### Interfaz
|
|
562
|
+
|
|
563
|
+
```typescript
|
|
564
|
+
interface Operation {
|
|
565
|
+
id: string;
|
|
566
|
+
type: 'insert' | 'delete' | 'update' | 'move';
|
|
567
|
+
path: string;
|
|
568
|
+
value?: unknown;
|
|
569
|
+
previousValue?: unknown;
|
|
570
|
+
position?: number;
|
|
571
|
+
length?: number;
|
|
572
|
+
participantId: string;
|
|
573
|
+
timestamp: string;
|
|
574
|
+
version: number;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
interface Conflict {
|
|
578
|
+
id: string;
|
|
579
|
+
operations: Operation[];
|
|
580
|
+
detectedAt: string;
|
|
581
|
+
status: 'pending' | 'auto-resolved' | 'manual-resolved' | 'rejected';
|
|
582
|
+
resolution?: ConflictResolution;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
interface ConflictResolution {
|
|
586
|
+
strategy: 'accept-first' | 'accept-last' | 'merge' | 'custom';
|
|
587
|
+
resultingOperation: Operation;
|
|
588
|
+
resolvedBy?: string;
|
|
589
|
+
resolvedAt: string;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
interface ConflictResolver {
|
|
593
|
+
// Detection
|
|
594
|
+
detectConflict(op1: Operation, op2: Operation): boolean;
|
|
595
|
+
getConflicts(sessionId: string): Conflict[];
|
|
596
|
+
|
|
597
|
+
// Resolution
|
|
598
|
+
resolve(conflictId: string, resolution: ConflictResolution): Promise<void>;
|
|
599
|
+
autoResolve(conflict: Conflict): Promise<ConflictResolution>;
|
|
600
|
+
rejectOperation(operationId: string): Promise<void>;
|
|
601
|
+
|
|
602
|
+
// Transformation (OT)
|
|
603
|
+
transform(op1: Operation, op2: Operation): [Operation, Operation];
|
|
604
|
+
transformAgainst(op: Operation, history: Operation[]): Operation;
|
|
605
|
+
|
|
606
|
+
// Events
|
|
607
|
+
onConflict(callback: (conflict: Conflict) => void): () => void;
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### Implementación
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
class ConflictResolverImpl implements ConflictResolver {
|
|
615
|
+
private conflicts: Map<string, Conflict> = new Map();
|
|
616
|
+
private conflictCallbacks: Set<(conflict: Conflict) => void> = new Set();
|
|
617
|
+
|
|
618
|
+
constructor(private settings: ConflictResolverSettings) {}
|
|
619
|
+
|
|
620
|
+
detectConflict(op1: Operation, op2: Operation): boolean {
|
|
621
|
+
// Same path
|
|
622
|
+
if (op1.path !== op2.path) return false;
|
|
623
|
+
|
|
624
|
+
// Same version (concurrent)
|
|
625
|
+
if (op1.version !== op2.version) return false;
|
|
626
|
+
|
|
627
|
+
// Different participants
|
|
628
|
+
if (op1.participantId === op2.participantId) return false;
|
|
629
|
+
|
|
630
|
+
// Conflicting operation types
|
|
631
|
+
const conflictingPairs = [
|
|
632
|
+
['update', 'update'],
|
|
633
|
+
['update', 'delete'],
|
|
634
|
+
['delete', 'delete'],
|
|
635
|
+
['insert', 'insert'] // at same position
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
const pair = [op1.type, op2.type].sort();
|
|
639
|
+
return conflictingPairs.some(cp => cp[0] === pair[0] && cp[1] === pair[1]);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async autoResolve(conflict: Conflict): Promise<ConflictResolution> {
|
|
643
|
+
const [op1, op2] = conflict.operations;
|
|
644
|
+
|
|
645
|
+
switch (this.settings.defaultStrategy) {
|
|
646
|
+
case 'last-write-wins':
|
|
647
|
+
// Use timestamp to determine winner
|
|
648
|
+
const winner = op1.timestamp > op2.timestamp ? op1 : op2;
|
|
649
|
+
return {
|
|
650
|
+
strategy: 'accept-last',
|
|
651
|
+
resultingOperation: winner,
|
|
652
|
+
resolvedAt: new Date().toISOString()
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
case 'first-write-wins':
|
|
656
|
+
const first = op1.timestamp < op2.timestamp ? op1 : op2;
|
|
657
|
+
return {
|
|
658
|
+
strategy: 'accept-first',
|
|
659
|
+
resultingOperation: first,
|
|
660
|
+
resolvedAt: new Date().toISOString()
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
case 'merge':
|
|
664
|
+
// Try to merge operations
|
|
665
|
+
const merged = this.mergeOperations(op1, op2);
|
|
666
|
+
return {
|
|
667
|
+
strategy: 'merge',
|
|
668
|
+
resultingOperation: merged,
|
|
669
|
+
resolvedAt: new Date().toISOString()
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
default:
|
|
673
|
+
// Use OT to transform
|
|
674
|
+
const [transformed1, transformed2] = this.transform(op1, op2);
|
|
675
|
+
return {
|
|
676
|
+
strategy: 'custom',
|
|
677
|
+
resultingOperation: transformed1,
|
|
678
|
+
resolvedAt: new Date().toISOString()
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
transform(op1: Operation, op2: Operation): [Operation, Operation] {
|
|
684
|
+
// Operational Transformation for concurrent operations
|
|
685
|
+
|
|
686
|
+
if (op1.type === 'insert' && op2.type === 'insert') {
|
|
687
|
+
// Both inserts - adjust positions
|
|
688
|
+
if (op1.position! <= op2.position!) {
|
|
689
|
+
return [
|
|
690
|
+
op1,
|
|
691
|
+
{ ...op2, position: op2.position! + (op1.value as string).length }
|
|
692
|
+
];
|
|
693
|
+
} else {
|
|
694
|
+
return [
|
|
695
|
+
{ ...op1, position: op1.position! + (op2.value as string).length },
|
|
696
|
+
op2
|
|
697
|
+
];
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (op1.type === 'delete' && op2.type === 'insert') {
|
|
702
|
+
if (op2.position! <= op1.position!) {
|
|
703
|
+
return [
|
|
704
|
+
{ ...op1, position: op1.position! + (op2.value as string).length },
|
|
705
|
+
op2
|
|
706
|
+
];
|
|
707
|
+
} else if (op2.position! >= op1.position! + op1.length!) {
|
|
708
|
+
return [op1, { ...op2, position: op2.position! - op1.length! }];
|
|
709
|
+
} else {
|
|
710
|
+
// Insert is within delete range - complex case
|
|
711
|
+
return [
|
|
712
|
+
{ ...op1, length: op1.length! + (op2.value as string).length },
|
|
713
|
+
{ ...op2, position: op1.position! }
|
|
714
|
+
];
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (op1.type === 'update' && op2.type === 'update') {
|
|
719
|
+
// Both updating same path - last write wins by default
|
|
720
|
+
const winner = op1.timestamp > op2.timestamp ? op1 : op2;
|
|
721
|
+
const loser = winner === op1 ? op2 : op1;
|
|
722
|
+
return [winner, { ...loser, type: 'update', value: winner.value }];
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Default: return as-is
|
|
726
|
+
return [op1, op2];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
transformAgainst(op: Operation, history: Operation[]): Operation {
|
|
730
|
+
let transformed = op;
|
|
731
|
+
|
|
732
|
+
for (const historicalOp of history) {
|
|
733
|
+
if (historicalOp.version > op.version) continue;
|
|
734
|
+
if (historicalOp.id === op.id) continue;
|
|
735
|
+
|
|
736
|
+
if (this.detectConflict(transformed, historicalOp)) {
|
|
737
|
+
const [newOp] = this.transform(transformed, historicalOp);
|
|
738
|
+
transformed = newOp;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return transformed;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private mergeOperations(op1: Operation, op2: Operation): Operation {
|
|
746
|
+
if (op1.type === 'update' && op2.type === 'update') {
|
|
747
|
+
// Merge object updates
|
|
748
|
+
if (typeof op1.value === 'object' && typeof op2.value === 'object') {
|
|
749
|
+
return {
|
|
750
|
+
...op1,
|
|
751
|
+
value: { ...op1.value as object, ...op2.value as object },
|
|
752
|
+
timestamp: new Date().toISOString()
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Merge string updates (concatenate)
|
|
757
|
+
if (typeof op1.value === 'string' && typeof op2.value === 'string') {
|
|
758
|
+
return {
|
|
759
|
+
...op1,
|
|
760
|
+
value: `${op1.value}\n---\n${op2.value}`,
|
|
761
|
+
timestamp: new Date().toISOString()
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Default: use last write
|
|
767
|
+
return op1.timestamp > op2.timestamp ? op1 : op2;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
onConflict(callback: (conflict: Conflict) => void): () => void {
|
|
771
|
+
this.conflictCallbacks.add(callback);
|
|
772
|
+
return () => this.conflictCallbacks.delete(callback);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
---
|
|
778
|
+
|
|
779
|
+
## 4. PresenceTracker
|
|
780
|
+
|
|
781
|
+
### Propósito
|
|
782
|
+
Rastrea la presencia de participantes incluyendo cursores, actividad y estado.
|
|
783
|
+
|
|
784
|
+
### Interfaz
|
|
785
|
+
|
|
786
|
+
```typescript
|
|
787
|
+
interface PresenceState {
|
|
788
|
+
participantId: string;
|
|
789
|
+
sessionId: string;
|
|
790
|
+
status: 'online' | 'away' | 'busy' | 'offline';
|
|
791
|
+
cursor?: CursorPosition;
|
|
792
|
+
selection?: SelectionRange;
|
|
793
|
+
activity?: ActivityInfo;
|
|
794
|
+
lastSeen: string;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
interface CursorPosition {
|
|
798
|
+
path: string; // e.g., "nodes[0].config"
|
|
799
|
+
line?: number;
|
|
800
|
+
column?: number;
|
|
801
|
+
label?: string; // What they're editing
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
interface SelectionRange {
|
|
805
|
+
start: CursorPosition;
|
|
806
|
+
end: CursorPosition;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
interface ActivityInfo {
|
|
810
|
+
type: 'editing' | 'viewing' | 'typing' | 'idle';
|
|
811
|
+
target?: string;
|
|
812
|
+
startedAt: string;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
interface PresenceTracker {
|
|
816
|
+
// Tracking
|
|
817
|
+
track(sessionId: string, participantId: string): void;
|
|
818
|
+
untrack(sessionId: string, participantId: string): void;
|
|
819
|
+
|
|
820
|
+
// Updates
|
|
821
|
+
updateStatus(sessionId: string, participantId: string, status: PresenceState['status']): void;
|
|
822
|
+
updateCursor(sessionId: string, participantId: string, cursor: CursorPosition): void;
|
|
823
|
+
updateSelection(sessionId: string, participantId: string, selection: SelectionRange): void;
|
|
824
|
+
updateActivity(sessionId: string, participantId: string, activity: ActivityInfo): void;
|
|
825
|
+
|
|
826
|
+
// Queries
|
|
827
|
+
getPresence(sessionId: string, participantId: string): PresenceState | null;
|
|
828
|
+
getAllPresence(sessionId: string): PresenceState[];
|
|
829
|
+
getOnlineParticipants(sessionId: string): string[];
|
|
830
|
+
|
|
831
|
+
// Events
|
|
832
|
+
onPresenceChange(sessionId: string, callback: (presence: PresenceState) => void): () => void;
|
|
833
|
+
}
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
### Implementación
|
|
837
|
+
|
|
838
|
+
```typescript
|
|
839
|
+
class PresenceTrackerImpl implements PresenceTracker {
|
|
840
|
+
private presence: Map<string, Map<string, PresenceState>> = new Map();
|
|
841
|
+
private idleTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
842
|
+
private callbacks: Map<string, Set<(presence: PresenceState) => void>> = new Map();
|
|
843
|
+
|
|
844
|
+
constructor(
|
|
845
|
+
private sync: RealtimeSync,
|
|
846
|
+
private config: PresenceConfig
|
|
847
|
+
) {
|
|
848
|
+
this.setupSyncListener();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private setupSyncListener(): void {
|
|
852
|
+
this.sync.onMessage((message) => {
|
|
853
|
+
if (message.type.startsWith('presence:')) {
|
|
854
|
+
this.handlePresenceMessage(message);
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
track(sessionId: string, participantId: string): void {
|
|
860
|
+
if (!this.presence.has(sessionId)) {
|
|
861
|
+
this.presence.set(sessionId, new Map());
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const state: PresenceState = {
|
|
865
|
+
participantId,
|
|
866
|
+
sessionId,
|
|
867
|
+
status: 'online',
|
|
868
|
+
lastSeen: new Date().toISOString()
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
this.presence.get(sessionId)!.set(participantId, state);
|
|
872
|
+
this.broadcastPresence(sessionId, state);
|
|
873
|
+
this.setupIdleDetection(sessionId, participantId);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
untrack(sessionId: string, participantId: string): void {
|
|
877
|
+
const sessionPresence = this.presence.get(sessionId);
|
|
878
|
+
if (!sessionPresence) return;
|
|
879
|
+
|
|
880
|
+
const state = sessionPresence.get(participantId);
|
|
881
|
+
if (state) {
|
|
882
|
+
state.status = 'offline';
|
|
883
|
+
this.broadcastPresence(sessionId, state);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
sessionPresence.delete(participantId);
|
|
887
|
+
this.clearIdleTimer(sessionId, participantId);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
updateCursor(
|
|
891
|
+
sessionId: string,
|
|
892
|
+
participantId: string,
|
|
893
|
+
cursor: CursorPosition
|
|
894
|
+
): void {
|
|
895
|
+
const state = this.getPresence(sessionId, participantId);
|
|
896
|
+
if (!state) return;
|
|
897
|
+
|
|
898
|
+
state.cursor = cursor;
|
|
899
|
+
state.lastSeen = new Date().toISOString();
|
|
900
|
+
|
|
901
|
+
this.broadcastPresence(sessionId, state);
|
|
902
|
+
this.resetIdleTimer(sessionId, participantId);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
updateActivity(
|
|
906
|
+
sessionId: string,
|
|
907
|
+
participantId: string,
|
|
908
|
+
activity: ActivityInfo
|
|
909
|
+
): void {
|
|
910
|
+
const state = this.getPresence(sessionId, participantId);
|
|
911
|
+
if (!state) return;
|
|
912
|
+
|
|
913
|
+
state.activity = activity;
|
|
914
|
+
state.lastSeen = new Date().toISOString();
|
|
915
|
+
|
|
916
|
+
// Auto-update status based on activity
|
|
917
|
+
if (activity.type === 'typing' || activity.type === 'editing') {
|
|
918
|
+
state.status = 'busy';
|
|
919
|
+
} else if (activity.type === 'idle') {
|
|
920
|
+
state.status = 'away';
|
|
921
|
+
} else {
|
|
922
|
+
state.status = 'online';
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
this.broadcastPresence(sessionId, state);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
getPresence(sessionId: string, participantId: string): PresenceState | null {
|
|
929
|
+
return this.presence.get(sessionId)?.get(participantId) || null;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
getAllPresence(sessionId: string): PresenceState[] {
|
|
933
|
+
const sessionPresence = this.presence.get(sessionId);
|
|
934
|
+
return sessionPresence ? Array.from(sessionPresence.values()) : [];
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
getOnlineParticipants(sessionId: string): string[] {
|
|
938
|
+
return this.getAllPresence(sessionId)
|
|
939
|
+
.filter(p => p.status !== 'offline')
|
|
940
|
+
.map(p => p.participantId);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private broadcastPresence(sessionId: string, state: PresenceState): void {
|
|
944
|
+
this.sync.broadcast(sessionId, {
|
|
945
|
+
type: 'presence:update',
|
|
946
|
+
senderId: state.participantId,
|
|
947
|
+
payload: state
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
this.notifyCallbacks(sessionId, state);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
private setupIdleDetection(sessionId: string, participantId: string): void {
|
|
954
|
+
const key = `${sessionId}:${participantId}`;
|
|
955
|
+
|
|
956
|
+
const timer = setTimeout(() => {
|
|
957
|
+
this.updateActivity(sessionId, participantId, {
|
|
958
|
+
type: 'idle',
|
|
959
|
+
startedAt: new Date().toISOString()
|
|
960
|
+
});
|
|
961
|
+
}, this.config.idleTimeout);
|
|
962
|
+
|
|
963
|
+
this.idleTimers.set(key, timer);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
private resetIdleTimer(sessionId: string, participantId: string): void {
|
|
967
|
+
this.clearIdleTimer(sessionId, participantId);
|
|
968
|
+
this.setupIdleDetection(sessionId, participantId);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private clearIdleTimer(sessionId: string, participantId: string): void {
|
|
972
|
+
const key = `${sessionId}:${participantId}`;
|
|
973
|
+
const timer = this.idleTimers.get(key);
|
|
974
|
+
if (timer) {
|
|
975
|
+
clearTimeout(timer);
|
|
976
|
+
this.idleTimers.delete(key);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
onPresenceChange(
|
|
981
|
+
sessionId: string,
|
|
982
|
+
callback: (presence: PresenceState) => void
|
|
983
|
+
): () => void {
|
|
984
|
+
if (!this.callbacks.has(sessionId)) {
|
|
985
|
+
this.callbacks.set(sessionId, new Set());
|
|
986
|
+
}
|
|
987
|
+
this.callbacks.get(sessionId)!.add(callback);
|
|
988
|
+
|
|
989
|
+
return () => {
|
|
990
|
+
this.callbacks.get(sessionId)?.delete(callback);
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private notifyCallbacks(sessionId: string, state: PresenceState): void {
|
|
995
|
+
this.callbacks.get(sessionId)?.forEach(cb => cb(state));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
private handlePresenceMessage(message: SyncMessage): void {
|
|
999
|
+
if (message.type === 'presence:update') {
|
|
1000
|
+
const state = message.payload as PresenceState;
|
|
1001
|
+
const sessionPresence = this.presence.get(message.sessionId);
|
|
1002
|
+
|
|
1003
|
+
if (sessionPresence) {
|
|
1004
|
+
sessionPresence.set(state.participantId, state);
|
|
1005
|
+
this.notifyCallbacks(message.sessionId, state);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
---
|
|
1013
|
+
|
|
1014
|
+
## 5. Visualización de Presencia
|
|
1015
|
+
|
|
1016
|
+
```
|
|
1017
|
+
╔══════════════════════════════════════════════════════════════════════════════╗
|
|
1018
|
+
║ Collaborative Session: Flow Design ║
|
|
1019
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
1020
|
+
║ Session ID: session_abc123 │ Participants: 4 │ Active: 3 ║
|
|
1021
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
1022
|
+
║ Participants ║
|
|
1023
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
1024
|
+
│ ● Alice (Host) 🟢 online Editing: nodes[2].config │
|
|
1025
|
+
│ ● Bob 🟢 online Viewing: flow overview │
|
|
1026
|
+
│ ● Agent-Explore 🟡 busy Executing: exploration task │
|
|
1027
|
+
│ ○ Carol ⚪ away Idle for 5 minutes │
|
|
1028
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
1029
|
+
║ Activity Feed ║
|
|
1030
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
1031
|
+
│ [10:32:15] Alice moved node "Analyze" to position (200, 150) │
|
|
1032
|
+
│ [10:32:08] Bob added comment on "Review" node │
|
|
1033
|
+
│ [10:31:45] Agent-Explore updated node "Explore" configuration │
|
|
1034
|
+
│ [10:31:30] Alice connected "Analyze" → "Implement" │
|
|
1035
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
1036
|
+
║ Cursors (Canvas) ║
|
|
1037
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
1038
|
+
│ │
|
|
1039
|
+
│ ┌─────────────┐ 🔴Alice │
|
|
1040
|
+
│ │ Analyze │ ↓ │
|
|
1041
|
+
│ └─────────────┘ ┌─────────────┐ │
|
|
1042
|
+
│ │ Implement │←🔵Bob │
|
|
1043
|
+
│ └─────────────┘ │
|
|
1044
|
+
│ │
|
|
1045
|
+
╚══════════════════════════════════════════════════════════════════════════════╝
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
---
|
|
1049
|
+
|
|
1050
|
+
## 6. Comandos CLI
|
|
1051
|
+
|
|
1052
|
+
```bash
|
|
1053
|
+
# Crear sesión colaborativa
|
|
1054
|
+
/elsabro:collab create "Flow Design Session" --type flow
|
|
1055
|
+
|
|
1056
|
+
# Unirse a sesión
|
|
1057
|
+
/elsabro:collab join <session-id>
|
|
1058
|
+
|
|
1059
|
+
# Invitar participante
|
|
1060
|
+
/elsabro:collab invite <email> --role editor
|
|
1061
|
+
|
|
1062
|
+
# Ver participantes
|
|
1063
|
+
/elsabro:collab participants
|
|
1064
|
+
|
|
1065
|
+
# Ver actividad
|
|
1066
|
+
/elsabro:collab activity
|
|
1067
|
+
|
|
1068
|
+
# Salir de sesión
|
|
1069
|
+
/elsabro:collab leave
|
|
1070
|
+
|
|
1071
|
+
# Cerrar sesión (solo host)
|
|
1072
|
+
/elsabro:collab close
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
---
|
|
1076
|
+
|
|
1077
|
+
## Referencias
|
|
1078
|
+
|
|
1079
|
+
- **REF-019**: Event-Driven Architecture
|
|
1080
|
+
- **REF-028**: Flow Visualization
|
|
1081
|
+
- **REF-030**: Esta referencia (Collaborative Sessions)
|