@fyresmith/hive-server 2.4.0 → 3.1.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.
@@ -1,45 +1,29 @@
1
1
  import { socketMiddleware } from './auth.js';
2
2
  import * as vault from './vaultManager.js';
3
- import { serverAdapterRegistry } from './adapterRegistry.js';
4
- import { negotiateProtocol, PROTOCOL_V1 } from './collabProtocol.js';
5
- import { createCollabStore } from './collabStore.js';
6
- import { parseMentions } from './mentionUtils.js';
7
- import { sendDiscordWebhook } from './discordWebhook.js';
8
3
 
9
4
  /**
10
- * presenceByFile: file path -> Set of socket IDs
5
+ * presenceByFile: file path Set of socket IDs
11
6
  * @type {Map<string, Set<string>>}
12
7
  */
13
8
  const presenceByFile = new Map();
14
9
 
15
10
  /**
16
- * socketToFiles: socket ID -> Set of file paths
11
+ * socketToFiles: socket ID Set of file paths (for cleanup on disconnect)
17
12
  * @type {Map<string, Set<string>>}
18
13
  */
19
14
  const socketToFiles = new Map();
20
15
 
21
16
  /**
22
- * userBySocket: socket ID -> user object
17
+ * userBySocket: socket ID user object
23
18
  * @type {Map<string, object>}
24
19
  */
25
20
  const userBySocket = new Map();
26
21
 
27
22
  /**
28
- * presenceStateBySocket: socket ID -> transient presence state
29
- * @type {Map<string, { lastSeenAt: number, protocolVersion: number, activeFile: string|null, cursor: any, viewport: any }>}
23
+ * claimedFiles: file path { socketId, userId, username, color }
24
+ * @type {Map<string, object>}
30
25
  */
31
- const presenceStateBySocket = new Map();
32
-
33
- const collabStore = createCollabStore();
34
- const SHA256_HEX_RE = /^[a-f0-9]{64}$/;
35
-
36
- const PRESENCE_STALE_MS = Math.max(
37
- 15000,
38
- parseInt(process.env.HIVE_PRESENCE_STALE_MS ?? '45000', 10) || 45000,
39
- );
40
- const PRESENCE_SWEEP_MS = Math.min(15000, Math.max(5000, Math.trunc(PRESENCE_STALE_MS / 3)));
41
-
42
- let staleSweepTimer = null;
26
+ const claimedFiles = new Map();
43
27
 
44
28
  function respond(cb, payload) {
45
29
  if (typeof cb === 'function') cb(payload);
@@ -60,243 +44,17 @@ function normalizeHexColor(color) {
60
44
  return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
61
45
  }
62
46
 
63
- function normalizeExpectedHash(value) {
64
- if (value === null) return null;
65
- if (typeof value !== 'string') return undefined;
66
- const normalized = value.trim().toLowerCase();
67
- if (!SHA256_HEX_RE.test(normalized)) return undefined;
68
- return normalized;
69
- }
70
-
71
- function parseHistoryLimit(raw) {
72
- if (raw === undefined || raw === null) return 50;
73
- const num = Number(raw);
74
- if (!Number.isFinite(num)) return 50;
75
- const int = Math.trunc(num);
76
- if (int < 1) return 1;
77
- if (int > 500) return 500;
78
- return int;
79
- }
80
-
81
- function actorFromUser(user) {
82
- return {
83
- id: user?.id ?? null,
84
- username: user?.username ?? null,
85
- };
86
- }
87
-
88
- function getWorkspaceKey(filePath) {
89
- if (typeof filePath !== 'string' || filePath.length === 0) return 'root';
90
- const normalized = filePath.replace(/\\/g, '/');
91
- const [head] = normalized.split('/');
92
- return head || 'root';
93
- }
94
-
95
- function normalizeAdapterContent(relPath, content, cb) {
96
- const adapter = serverAdapterRegistry.getByPath(relPath);
97
- if (!adapter) return true;
98
-
99
- const ok = adapter.validateContent(content);
100
- if (!ok) {
101
- respond(cb, {
102
- ok: false,
103
- code: 'BAD_REQUEST',
104
- error: `Invalid payload for adapter ${adapter.adapterId}`,
105
- adapterId: adapter.adapterId,
106
- });
107
- }
108
- return ok;
109
- }
110
-
111
- function ensureState(socketId) {
112
- let state = presenceStateBySocket.get(socketId);
113
- if (!state) {
114
- state = {
115
- lastSeenAt: Date.now(),
116
- protocolVersion: PROTOCOL_V1,
117
- activeFile: null,
118
- cursor: null,
119
- viewport: null,
120
- };
121
- presenceStateBySocket.set(socketId, state);
122
- }
123
- return state;
124
- }
125
-
126
- function touchPresence(socketId, patch = null) {
127
- const state = ensureState(socketId);
128
- state.lastSeenAt = Date.now();
129
- if (patch && typeof patch === 'object') {
130
- if (patch.activeFile === null || typeof patch.activeFile === 'string') {
131
- state.activeFile = patch.activeFile;
132
- }
133
- if (patch.cursor !== undefined) {
134
- state.cursor = patch.cursor;
135
- }
136
- if (patch.viewport !== undefined) {
137
- state.viewport = patch.viewport;
138
- }
139
- if (Number.isInteger(patch.protocolVersion)) {
140
- state.protocolVersion = patch.protocolVersion;
141
- }
142
- }
143
- presenceStateBySocket.set(socketId, state);
144
- return state;
145
- }
146
-
147
- function presenceList() {
148
- const out = [];
149
- for (const [socketId, user] of userBySocket) {
150
- const state = presenceStateBySocket.get(socketId);
151
- const openFiles = [...(socketToFiles.get(socketId) ?? new Set())];
152
- out.push({
153
- socketId,
154
- user,
155
- openFiles,
156
- activeFile: state?.activeFile ?? openFiles[0] ?? null,
157
- cursor: state?.cursor ?? null,
158
- viewport: state?.viewport ?? null,
159
- lastSeenAt: state?.lastSeenAt ?? null,
160
- stale: state ? (Date.now() - state.lastSeenAt) > PRESENCE_STALE_MS : true,
161
- });
162
- }
163
- return out;
164
- }
165
-
166
- function getSocketsByUserId(userId) {
167
- const out = [];
168
- for (const [socketId, user] of userBySocket) {
169
- if (user?.id === userId) out.push(socketId);
170
- }
171
- return out;
172
- }
173
-
174
- function getUsersByMention(mentionUsername) {
175
- const username = String(mentionUsername).toLowerCase();
176
- const seen = new Set();
177
- const users = [];
178
-
179
- for (const user of userBySocket.values()) {
180
- if (!user?.id || !user?.username) continue;
181
- if (String(user.username).toLowerCase() !== username) continue;
182
- if (seen.has(user.id)) continue;
183
- seen.add(user.id);
184
- users.push(user);
185
- }
186
-
187
- return users;
188
- }
189
-
190
- async function maybeDeliverNotifyEvent(io, event) {
191
- const decision = await collabStore.shouldDeliverNotification(event.targetUser.id, event.filePath, event.kind);
192
- if (!decision.deliver) {
193
- return;
194
- }
195
-
196
- const payload = {
197
- ...event,
198
- mode: decision.mode,
199
- ts: Date.now(),
200
- };
201
-
202
- const targetSockets = getSocketsByUserId(event.targetUser.id);
203
- for (const socketId of targetSockets) {
204
- io.to(socketId).emit('collab:notify:event', payload);
205
- }
206
-
207
- await sendDiscordWebhook(payload);
208
- }
209
-
210
- function activityRoomForWorkspace() {
211
- return 'collab:activity:workspace';
212
- }
213
-
214
- function activityRoomForFile(filePath) {
215
- return `collab:activity:file:${filePath}`;
216
- }
217
-
218
- function publishActivity(io, activity) {
219
- io.to(activityRoomForWorkspace()).emit('collab:activity:event', { activity });
220
- if (activity?.filePath) {
221
- io.to(activityRoomForFile(activity.filePath)).emit('collab:activity:event', { activity });
222
- }
223
- }
224
-
225
- async function recordActivity(io, { type, filePath = null, actor = null, payload = null, groupKey = null }) {
226
- const activity = await collabStore.recordActivity({
227
- type,
228
- filePath,
229
- actor,
230
- payload,
231
- groupKey,
232
- });
233
- publishActivity(io, activity);
234
- return activity;
235
- }
236
-
237
- async function getCurrentHash(relPath) {
238
- try {
239
- const currentContent = await vault.readFile(relPath);
240
- return vault.hashContent(currentContent);
241
- } catch (err) {
242
- if (err && err.code === 'ENOENT') return null;
243
- throw err;
244
- }
245
- }
246
-
247
- function ensureStaleSweep(io) {
248
- if (staleSweepTimer) return;
249
-
250
- staleSweepTimer = setInterval(() => {
251
- const now = Date.now();
252
-
253
- for (const [socketId, state] of presenceStateBySocket) {
254
- if (state.protocolVersion < 2) continue;
255
- if (now - state.lastSeenAt <= PRESENCE_STALE_MS) continue;
256
-
257
- const user = userBySocket.get(socketId);
258
- const openFiles = socketToFiles.get(socketId) ?? new Set();
259
-
260
- for (const relPath of openFiles) {
261
- presenceByFile.get(relPath)?.delete(socketId);
262
- io.emit('presence-file-closed', {
263
- relPath,
264
- user,
265
- stale: true,
266
- });
267
- }
268
-
269
- openFiles.clear();
270
- socketToFiles.set(socketId, openFiles);
271
- state.activeFile = null;
272
- state.cursor = null;
273
- state.viewport = null;
274
- state.lastSeenAt = now;
275
-
276
- io.emit('collab:presence:stale', {
277
- user,
278
- socketId,
279
- ts: now,
280
- });
281
- }
282
- }, PRESENCE_SWEEP_MS);
283
-
284
- if (typeof staleSweepTimer.unref === 'function') {
285
- staleSweepTimer.unref();
286
- }
287
- }
288
-
289
47
  /**
290
48
  * Attach all Socket.IO event handlers.
291
49
  *
292
50
  * @param {import('socket.io').Server} io
293
- * @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
51
+ * @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
294
52
  * @param {(relPath: string, hash: string, excludeSocketId: string|null) => void} broadcastFileUpdated
295
53
  * @param {(relPath: string) => Promise<void>} forceCloseRoom
296
54
  */
297
55
  export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom) {
56
+ // Auth middleware for every socket connection
298
57
  io.use(socketMiddleware);
299
- ensureStaleSweep(io);
300
58
 
301
59
  io.on('connection', (socket) => {
302
60
  const user = socket.user;
@@ -304,99 +62,14 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
304
62
 
305
63
  userBySocket.set(socket.id, user);
306
64
  socketToFiles.set(socket.id, new Set());
307
- touchPresence(socket.id);
308
65
 
66
+ // Notify others this user joined
309
67
  socket.broadcast.emit('user-joined', { user });
310
68
 
311
- socket.on('collab:hello', (payload, cb) => {
312
- try {
313
- const negotiated = negotiateProtocol(payload, serverAdapterRegistry);
314
- const protocolVersion = negotiated.negotiatedProtocol;
315
- touchPresence(socket.id, { protocolVersion });
316
- respond(cb, { ok: true, ...negotiated });
317
- } catch (err) {
318
- respond(cb, { ok: false, error: err?.message ?? String(err) });
319
- }
320
- });
321
-
322
- socket.on('collab:presence:heartbeat', (payload, cb) => {
323
- const relPath = typeof payload?.activeFile === 'string' ? payload.activeFile : null;
324
- if (relPath && !isAllowedPath(relPath)) {
325
- rejectPath(cb, relPath);
326
- return;
327
- }
328
-
329
- const state = touchPresence(socket.id, {
330
- activeFile: relPath,
331
- cursor: payload?.cursor ?? null,
332
- viewport: payload?.viewport ?? null,
333
- });
334
-
335
- socket.broadcast.emit('collab:presence:heartbeat', {
336
- user,
337
- location: {
338
- activeFile: state.activeFile,
339
- cursor: state.cursor,
340
- viewport: state.viewport,
341
- },
342
- ts: state.lastSeenAt,
343
- });
344
-
345
- respond(cb, {
346
- ok: true,
347
- lastSeenAt: state.lastSeenAt,
348
- });
349
- });
350
-
351
- socket.on('collab:presence:list', (_payload, cb) => {
352
- respond(cb, {
353
- ok: true,
354
- users: presenceList(),
355
- });
356
- });
357
-
358
- socket.on('collab:presence:jump', (payload, cb) => {
359
- const userId = payload?.userId;
360
- if (typeof userId !== 'string' || userId.length === 0) {
361
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing userId' });
362
- return;
363
- }
364
-
365
- let target = null;
366
- for (const [socketId, currentUser] of userBySocket) {
367
- if (currentUser?.id !== userId) continue;
368
- const state = presenceStateBySocket.get(socketId);
369
- const openFiles = [...(socketToFiles.get(socketId) ?? new Set())];
370
- const activeFile = state?.activeFile ?? openFiles[0] ?? null;
371
- if (!activeFile) continue;
372
- target = {
373
- user: currentUser,
374
- location: {
375
- activeFile,
376
- cursor: state?.cursor ?? null,
377
- viewport: state?.viewport ?? null,
378
- },
379
- };
380
- break;
381
- }
382
-
383
- if (!target) {
384
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Collaborator location unavailable' });
385
- return;
386
- }
387
-
388
- respond(cb, {
389
- ok: true,
390
- ...target,
391
- });
392
- });
393
-
394
69
  // -----------------------------------------------------------------------
395
- // Legacy sync events (kept for v1 compatibility)
70
+ // vault-sync-request
396
71
  // -----------------------------------------------------------------------
397
-
398
72
  socket.on('vault-sync-request', async (cb) => {
399
- touchPresence(socket.id);
400
73
  try {
401
74
  const manifest = await vault.getManifest();
402
75
  respond(cb, { ok: true, manifest });
@@ -406,8 +79,10 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
406
79
  }
407
80
  });
408
81
 
82
+ // -----------------------------------------------------------------------
83
+ // file-read
84
+ // -----------------------------------------------------------------------
409
85
  socket.on('file-read', async (relPath, cb) => {
410
- touchPresence(socket.id);
411
86
  if (!isAllowedPath(relPath)) {
412
87
  rejectPath(cb, relPath);
413
88
  return;
@@ -422,56 +97,21 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
422
97
  }
423
98
  });
424
99
 
100
+ // -----------------------------------------------------------------------
101
+ // file-write
102
+ // -----------------------------------------------------------------------
425
103
  socket.on('file-write', async (payload, cb) => {
426
- touchPresence(socket.id);
427
104
  const relPath = payload?.relPath;
428
105
  const content = payload?.content;
429
- const expectedHash = normalizeExpectedHash(payload?.expectedHash);
430
106
  if (!isAllowedPath(relPath) || typeof content !== 'string') {
431
107
  rejectPath(cb, relPath);
432
108
  return;
433
109
  }
434
- if (payload?.expectedHash !== undefined && expectedHash === undefined) {
435
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Invalid expectedHash' });
436
- return;
437
- }
438
- if (!normalizeAdapterContent(relPath, content, cb)) {
439
- return;
440
- }
441
-
442
110
  try {
443
- const currentHash = await getCurrentHash(relPath);
444
- if (expectedHash !== undefined && expectedHash !== currentHash) {
445
- respond(cb, {
446
- ok: false,
447
- code: 'CONFLICT',
448
- error: 'Write conflict: file changed on server',
449
- relPath,
450
- currentHash,
451
- });
452
- return;
453
- }
454
-
455
- await vault.writeFile(relPath, content, {
456
- history: {
457
- action: 'write',
458
- source: 'socket',
459
- actor: actorFromUser(user),
460
- },
461
- });
111
+ await vault.writeFile(relPath, content);
462
112
  const hash = vault.hashContent(content);
463
113
  socket.broadcast.emit('file-updated', { relPath, hash, user });
464
- broadcastFileUpdated?.(relPath, hash, socket.id);
465
- respond(cb, { ok: true, hash, previousHash: currentHash });
466
-
467
- await recordActivity(io, {
468
- type: 'edit',
469
- filePath: relPath,
470
- actor: actorFromUser(user),
471
- payload: { source: 'socket', adapterId: serverAdapterRegistry.getByPath(relPath)?.adapterId ?? null },
472
- groupKey: `${user.id}:${relPath}:edit`,
473
- });
474
-
114
+ respond(cb, { ok: true, hash });
475
115
  console.log(`[socket] file-write: ${relPath} by ${user.username}`);
476
116
  } catch (err) {
477
117
  console.error(`[socket] file-write error (${relPath}):`, err);
@@ -479,38 +119,20 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
479
119
  }
480
120
  });
481
121
 
122
+ // -----------------------------------------------------------------------
123
+ // file-create
124
+ // -----------------------------------------------------------------------
482
125
  socket.on('file-create', async (payload, cb) => {
483
- touchPresence(socket.id);
484
126
  const relPath = payload?.relPath;
485
127
  const content = payload?.content;
486
128
  if (!isAllowedPath(relPath) || typeof content !== 'string') {
487
129
  rejectPath(cb, relPath);
488
130
  return;
489
131
  }
490
- if (!normalizeAdapterContent(relPath, content, cb)) {
491
- return;
492
- }
493
-
494
132
  try {
495
- await vault.writeFile(relPath, content, {
496
- history: {
497
- action: 'create',
498
- source: 'socket',
499
- actor: actorFromUser(user),
500
- },
501
- });
502
- const hash = vault.hashContent(content);
133
+ await vault.writeFile(relPath, content);
503
134
  io.emit('file-created', { relPath, user });
504
- respond(cb, { ok: true, hash });
505
-
506
- await recordActivity(io, {
507
- type: 'create',
508
- filePath: relPath,
509
- actor: actorFromUser(user),
510
- payload: { source: 'socket' },
511
- groupKey: `${user.id}:${relPath}:create`,
512
- });
513
-
135
+ respond(cb, { ok: true });
514
136
  console.log(`[socket] file-create: ${relPath} by ${user.username}`);
515
137
  } catch (err) {
516
138
  console.error(`[socket] file-create error (${relPath}):`, err);
@@ -518,35 +140,23 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
518
140
  }
519
141
  });
520
142
 
143
+ // -----------------------------------------------------------------------
144
+ // file-delete
145
+ // -----------------------------------------------------------------------
521
146
  socket.on('file-delete', async (relPath, cb) => {
522
- touchPresence(socket.id);
523
147
  if (!isAllowedPath(relPath)) {
524
148
  rejectPath(cb, relPath);
525
149
  return;
526
150
  }
527
151
  try {
152
+ // Force-close active Yjs room first
528
153
  const docName = encodeURIComponent(relPath);
529
154
  if (getActiveRooms().has(docName)) {
530
155
  await forceCloseRoom(relPath);
531
156
  }
532
- await vault.deleteFile(relPath, {
533
- history: {
534
- action: 'delete',
535
- source: 'socket',
536
- actor: actorFromUser(user),
537
- },
538
- });
157
+ await vault.deleteFile(relPath);
539
158
  io.emit('file-deleted', { relPath, user });
540
159
  respond(cb, { ok: true });
541
-
542
- await recordActivity(io, {
543
- type: 'delete',
544
- filePath: relPath,
545
- actor: actorFromUser(user),
546
- payload: { source: 'socket' },
547
- groupKey: `${user.id}:${relPath}:delete`,
548
- });
549
-
550
160
  console.log(`[socket] file-delete: ${relPath} by ${user.username}`);
551
161
  } catch (err) {
552
162
  console.error(`[socket] file-delete error (${relPath}):`, err);
@@ -554,8 +164,10 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
554
164
  }
555
165
  });
556
166
 
167
+ // -----------------------------------------------------------------------
168
+ // file-rename
169
+ // -----------------------------------------------------------------------
557
170
  socket.on('file-rename', async (payload, cb) => {
558
- touchPresence(socket.id);
559
171
  const oldPath = payload?.oldPath;
560
172
  const newPath = payload?.newPath;
561
173
  if (!isAllowedPath(oldPath) || !isAllowedPath(newPath)) {
@@ -563,506 +175,80 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
563
175
  return;
564
176
  }
565
177
  try {
178
+ // Force-close active Yjs room for old path
566
179
  const docName = encodeURIComponent(oldPath);
567
180
  if (getActiveRooms().has(docName)) {
568
181
  await forceCloseRoom(oldPath);
569
182
  }
570
- await vault.renameFile(oldPath, newPath, {
571
- history: {
572
- source: 'socket',
573
- actor: actorFromUser(user),
574
- },
575
- });
183
+ await vault.renameFile(oldPath, newPath);
576
184
  io.emit('file-renamed', { oldPath, newPath, user });
577
185
  respond(cb, { ok: true });
578
-
579
- await recordActivity(io, {
580
- type: 'rename',
581
- filePath: newPath,
582
- actor: actorFromUser(user),
583
- payload: { oldPath, newPath, source: 'socket' },
584
- groupKey: `${user.id}:${oldPath}:rename`,
585
- });
586
-
587
- console.log(`[socket] file-rename: ${oldPath} -> ${newPath} by ${user.username}`);
186
+ console.log(`[socket] file-rename: ${oldPath} → ${newPath} by ${user.username}`);
588
187
  } catch (err) {
589
188
  console.error(`[socket] file-rename error (${oldPath}):`, err);
590
189
  respond(cb, { ok: false, error: err.message });
591
190
  }
592
191
  });
593
192
 
594
- socket.on('file-history-list', async (payload, cb) => {
595
- touchPresence(socket.id);
596
- const relPath = payload?.relPath;
597
- if (!isAllowedPath(relPath)) {
598
- rejectPath(cb, relPath);
599
- return;
600
- }
601
- try {
602
- const limit = parseHistoryLimit(payload?.limit);
603
- const versions = await vault.listFileHistory(relPath, limit);
604
- respond(cb, { ok: true, versions });
605
- } catch (err) {
606
- console.error(`[socket] file-history-list error (${relPath}):`, err);
607
- respond(cb, { ok: false, error: err.message });
608
- }
193
+ // -----------------------------------------------------------------------
194
+ // file-claim
195
+ // -----------------------------------------------------------------------
196
+ socket.on('file-claim', ({ relPath } = {}, cb) => {
197
+ if (!isAllowedPath(relPath)) return respond(cb, { ok: false, error: 'Not allowed' });
198
+ const claimUser = userBySocket.get(socket.id);
199
+ if (!claimUser) return;
200
+ claimedFiles.set(relPath, { socketId: socket.id, ...claimUser });
201
+ io.emit('file-claimed', { relPath, user: claimUser });
202
+ respond(cb, { ok: true });
609
203
  });
610
204
 
611
- socket.on('file-history-read', async (payload, cb) => {
612
- touchPresence(socket.id);
613
- const relPath = payload?.relPath;
614
- const versionId = payload?.versionId;
615
- if (!isAllowedPath(relPath)) {
616
- rejectPath(cb, relPath);
617
- return;
618
- }
619
- if (typeof versionId !== 'string' || versionId.length === 0) {
620
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing versionId' });
621
- return;
622
- }
623
- try {
624
- const version = await vault.readFileHistoryVersion(relPath, versionId);
625
- respond(cb, { ok: true, version });
626
- } catch (err) {
627
- console.error(`[socket] file-history-read error (${relPath} @ ${versionId}):`, err);
628
- respond(cb, { ok: false, error: err.message });
629
- }
205
+ // -----------------------------------------------------------------------
206
+ // file-unclaim
207
+ // -----------------------------------------------------------------------
208
+ socket.on('file-unclaim', ({ relPath } = {}, cb) => {
209
+ const claim = claimedFiles.get(relPath);
210
+ if (claim?.socketId !== socket.id) return respond(cb, { ok: false, error: 'Not your claim' });
211
+ claimedFiles.delete(relPath);
212
+ io.emit('file-unclaimed', { relPath, userId: userBySocket.get(socket.id)?.id });
213
+ respond(cb, { ok: true });
214
+ });
215
+
216
+ // -----------------------------------------------------------------------
217
+ // user-status-changed
218
+ // -----------------------------------------------------------------------
219
+ socket.on('user-status-changed', ({ status } = {}) => {
220
+ const statusUser = userBySocket.get(socket.id);
221
+ if (!statusUser) return;
222
+ socket.broadcast.emit('user-status-changed', { userId: statusUser.id, status });
630
223
  });
631
224
 
225
+ // -----------------------------------------------------------------------
226
+ // presence-file-opened
227
+ // -----------------------------------------------------------------------
632
228
  socket.on('presence-file-opened', (payload) => {
633
229
  const relPath = typeof payload === 'string' ? payload : payload?.relPath;
634
230
  const color = normalizeHexColor(typeof payload === 'string' ? null : payload?.color);
635
231
  if (!isAllowedPath(relPath)) return;
636
-
637
232
  if (!presenceByFile.has(relPath)) presenceByFile.set(relPath, new Set());
638
233
  presenceByFile.get(relPath).add(socket.id);
639
234
  socketToFiles.get(socket.id)?.add(relPath);
640
-
641
- touchPresence(socket.id, { activeFile: relPath });
642
235
  const presenceUser = color ? { ...user, color } : user;
643
236
  socket.broadcast.emit('presence-file-opened', { relPath, user: presenceUser });
644
237
  });
645
238
 
239
+ // -----------------------------------------------------------------------
240
+ // presence-file-closed
241
+ // -----------------------------------------------------------------------
646
242
  socket.on('presence-file-closed', (relPath) => {
647
243
  if (!isAllowedPath(relPath)) return;
648
244
  presenceByFile.get(relPath)?.delete(socket.id);
649
245
  socketToFiles.get(socket.id)?.delete(relPath);
650
-
651
- const state = touchPresence(socket.id);
652
- if (state.activeFile === relPath) {
653
- state.activeFile = null;
654
- }
655
-
656
246
  socket.broadcast.emit('presence-file-closed', { relPath, user });
657
247
  });
658
248
 
659
249
  // -----------------------------------------------------------------------
660
- // Collaboration artifacts / activity / notifications
250
+ // disconnect
661
251
  // -----------------------------------------------------------------------
662
-
663
- socket.on('collab:thread:list', async (payload, cb) => {
664
- touchPresence(socket.id);
665
- const filePath = payload?.filePath;
666
- if (filePath && !isAllowedPath(filePath)) {
667
- rejectPath(cb, filePath);
668
- return;
669
- }
670
-
671
- try {
672
- const threads = await collabStore.listThreads({
673
- filePath: filePath ?? null,
674
- status: payload?.status ?? null,
675
- });
676
- respond(cb, { ok: true, threads });
677
- } catch (err) {
678
- respond(cb, { ok: false, error: err?.message ?? String(err) });
679
- }
680
- });
681
-
682
- socket.on('collab:thread:create', async (payload, cb) => {
683
- touchPresence(socket.id);
684
- const filePath = payload?.filePath;
685
- if (!isAllowedPath(filePath)) {
686
- rejectPath(cb, filePath);
687
- return;
688
- }
689
-
690
- try {
691
- const body = typeof payload?.body === 'string' ? payload.body : '';
692
- const mentions = parseMentions(body);
693
- const thread = await collabStore.createThread({
694
- filePath,
695
- anchor: payload?.anchor ?? null,
696
- author: actorFromUser(user),
697
- body,
698
- mentions,
699
- });
700
-
701
- io.emit('collab:thread:created', { thread });
702
- await recordActivity(io, {
703
- type: 'comment',
704
- filePath,
705
- actor: actorFromUser(user),
706
- payload: { threadId: thread.threadId, kind: 'thread-created' },
707
- groupKey: `${user.id}:${filePath}:comment`,
708
- });
709
-
710
- if (mentions.length > 0) {
711
- for (const mention of mentions) {
712
- const targets = getUsersByMention(mention);
713
- for (const targetUser of targets) {
714
- if (targetUser.id === user.id) continue;
715
- await maybeDeliverNotifyEvent(io, {
716
- kind: 'mention',
717
- filePath,
718
- actor: actorFromUser(user),
719
- targetUser,
720
- threadId: thread.threadId,
721
- body,
722
- });
723
- }
724
- }
725
- }
726
-
727
- respond(cb, { ok: true, thread });
728
- } catch (err) {
729
- respond(cb, { ok: false, error: err?.message ?? String(err) });
730
- }
731
- });
732
-
733
- socket.on('collab:thread:update', async (payload, cb) => {
734
- touchPresence(socket.id);
735
- const threadId = payload?.threadId;
736
- if (typeof threadId !== 'string' || threadId.length === 0) {
737
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId' });
738
- return;
739
- }
740
-
741
- try {
742
- const thread = await collabStore.updateThread({
743
- threadId,
744
- patch: payload?.patch ?? {},
745
- });
746
- if (!thread) {
747
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
748
- return;
749
- }
750
-
751
- io.emit('collab:thread:updated', { thread });
752
- await recordActivity(io, {
753
- type: 'comment',
754
- filePath: thread.filePath,
755
- actor: actorFromUser(user),
756
- payload: { threadId: thread.threadId, kind: 'thread-updated' },
757
- groupKey: `${user.id}:${thread.filePath}:comment`,
758
- });
759
-
760
- respond(cb, { ok: true, thread });
761
- } catch (err) {
762
- respond(cb, { ok: false, error: err?.message ?? String(err) });
763
- }
764
- });
765
-
766
- socket.on('collab:thread:delete', async (payload, cb) => {
767
- touchPresence(socket.id);
768
- const threadId = payload?.threadId;
769
- if (typeof threadId !== 'string' || threadId.length === 0) {
770
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId' });
771
- return;
772
- }
773
-
774
- try {
775
- const thread = await collabStore.deleteThread(threadId);
776
- if (!thread) {
777
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
778
- return;
779
- }
780
-
781
- io.emit('collab:thread:deleted', { thread });
782
- await recordActivity(io, {
783
- type: 'comment',
784
- filePath: thread.filePath,
785
- actor: actorFromUser(user),
786
- payload: { threadId, kind: 'thread-archived' },
787
- groupKey: `${user.id}:${thread.filePath}:comment`,
788
- });
789
-
790
- respond(cb, { ok: true, thread });
791
- } catch (err) {
792
- respond(cb, { ok: false, error: err?.message ?? String(err) });
793
- }
794
- });
795
-
796
- socket.on('collab:comment:create', async (payload, cb) => {
797
- touchPresence(socket.id);
798
- const threadId = payload?.threadId;
799
- const body = typeof payload?.body === 'string' ? payload.body : '';
800
- if (typeof threadId !== 'string' || threadId.length === 0) {
801
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId' });
802
- return;
803
- }
804
- if (body.trim().length === 0) {
805
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing comment body' });
806
- return;
807
- }
808
-
809
- try {
810
- const mentions = parseMentions(body);
811
- const result = await collabStore.createComment({
812
- threadId,
813
- author: actorFromUser(user),
814
- body,
815
- mentions,
816
- });
817
- if (!result) {
818
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
819
- return;
820
- }
821
-
822
- io.emit('collab:comment:created', {
823
- threadId,
824
- comment: result.comment,
825
- thread: result.thread,
826
- });
827
-
828
- await recordActivity(io, {
829
- type: 'comment',
830
- filePath: result.thread.filePath,
831
- actor: actorFromUser(user),
832
- payload: { threadId, commentId: result.comment.commentId, kind: 'comment-created' },
833
- groupKey: `${user.id}:${result.thread.filePath}:comment`,
834
- });
835
-
836
- if (mentions.length > 0) {
837
- for (const mention of mentions) {
838
- const targets = getUsersByMention(mention);
839
- for (const targetUser of targets) {
840
- if (targetUser.id === user.id) continue;
841
- await maybeDeliverNotifyEvent(io, {
842
- kind: 'mention',
843
- filePath: result.thread.filePath,
844
- actor: actorFromUser(user),
845
- targetUser,
846
- threadId,
847
- body,
848
- });
849
- }
850
- }
851
- }
852
-
853
- respond(cb, { ok: true, ...result });
854
- } catch (err) {
855
- respond(cb, { ok: false, error: err?.message ?? String(err) });
856
- }
857
- });
858
-
859
- socket.on('collab:comment:update', async (payload, cb) => {
860
- touchPresence(socket.id);
861
- const threadId = payload?.threadId;
862
- const commentId = payload?.commentId;
863
- const body = typeof payload?.body === 'string' ? payload.body : '';
864
- if (typeof threadId !== 'string' || threadId.length === 0 || typeof commentId !== 'string' || commentId.length === 0) {
865
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId/commentId' });
866
- return;
867
- }
868
-
869
- try {
870
- const result = await collabStore.updateComment({ threadId, commentId, body });
871
- if (!result) {
872
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Comment not found' });
873
- return;
874
- }
875
-
876
- io.emit('collab:comment:updated', {
877
- threadId,
878
- comment: result.comment,
879
- thread: result.thread,
880
- });
881
-
882
- await recordActivity(io, {
883
- type: 'comment',
884
- filePath: result.thread.filePath,
885
- actor: actorFromUser(user),
886
- payload: { threadId, commentId, kind: 'comment-updated' },
887
- groupKey: `${user.id}:${result.thread.filePath}:comment`,
888
- });
889
-
890
- respond(cb, { ok: true, ...result });
891
- } catch (err) {
892
- respond(cb, { ok: false, error: err?.message ?? String(err) });
893
- }
894
- });
895
-
896
- socket.on('collab:comment:delete', async (payload, cb) => {
897
- touchPresence(socket.id);
898
- const threadId = payload?.threadId;
899
- const commentId = payload?.commentId;
900
- if (typeof threadId !== 'string' || threadId.length === 0 || typeof commentId !== 'string' || commentId.length === 0) {
901
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId/commentId' });
902
- return;
903
- }
904
-
905
- try {
906
- const result = await collabStore.deleteComment({ threadId, commentId });
907
- if (!result) {
908
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Comment not found' });
909
- return;
910
- }
911
-
912
- io.emit('collab:comment:deleted', {
913
- threadId,
914
- commentId,
915
- thread: result.thread,
916
- });
917
-
918
- await recordActivity(io, {
919
- type: 'comment',
920
- filePath: result.thread.filePath,
921
- actor: actorFromUser(user),
922
- payload: { threadId, commentId, kind: 'comment-deleted' },
923
- groupKey: `${user.id}:${result.thread.filePath}:comment`,
924
- });
925
-
926
- respond(cb, { ok: true, ...result });
927
- } catch (err) {
928
- respond(cb, { ok: false, error: err?.message ?? String(err) });
929
- }
930
- });
931
-
932
- socket.on('collab:task:set-state', async (payload, cb) => {
933
- touchPresence(socket.id);
934
- const threadId = payload?.threadId;
935
- const status = payload?.status;
936
- if (typeof threadId !== 'string' || threadId.length === 0 || (status !== 'open' && status !== 'resolved')) {
937
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Invalid threadId/status' });
938
- return;
939
- }
940
-
941
- try {
942
- const result = await collabStore.setTaskState({
943
- threadId,
944
- status,
945
- assignee: payload?.assignee ?? null,
946
- dueAt: payload?.dueAt ?? null,
947
- });
948
- if (!result) {
949
- respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
950
- return;
951
- }
952
-
953
- io.emit('collab:task:updated', {
954
- threadId,
955
- task: result.task,
956
- thread: result.thread,
957
- });
958
-
959
- await recordActivity(io, {
960
- type: 'task',
961
- filePath: result.thread.filePath,
962
- actor: actorFromUser(user),
963
- payload: { threadId, taskId: result.task.taskId, status },
964
- groupKey: `${user.id}:${result.thread.filePath}:task`,
965
- });
966
-
967
- if (result.task.assignee?.username) {
968
- const assigneeName = String(result.task.assignee.username).toLowerCase();
969
- const targets = getUsersByMention(assigneeName);
970
- for (const targetUser of targets) {
971
- if (targetUser.id === user.id) continue;
972
- await maybeDeliverNotifyEvent(io, {
973
- kind: 'task',
974
- filePath: result.thread.filePath,
975
- actor: actorFromUser(user),
976
- targetUser,
977
- threadId,
978
- body: `Task set to ${status}`,
979
- });
980
- }
981
- }
982
-
983
- respond(cb, { ok: true, ...result });
984
- } catch (err) {
985
- respond(cb, { ok: false, error: err?.message ?? String(err) });
986
- }
987
- });
988
-
989
- socket.on('collab:activity:list', async (payload, cb) => {
990
- touchPresence(socket.id);
991
- const filePath = payload?.filePath ?? null;
992
- if (filePath && !isAllowedPath(filePath)) {
993
- rejectPath(cb, filePath);
994
- return;
995
- }
996
-
997
- try {
998
- const result = await collabStore.listActivity({
999
- filePath,
1000
- types: Array.isArray(payload?.types) ? payload.types : null,
1001
- limit: payload?.limit,
1002
- cursor: payload?.cursor ?? null,
1003
- });
1004
- respond(cb, { ok: true, ...result });
1005
- } catch (err) {
1006
- respond(cb, { ok: false, error: err?.message ?? String(err) });
1007
- }
1008
- });
1009
-
1010
- socket.on('collab:activity:subscribe', (payload, cb) => {
1011
- touchPresence(socket.id);
1012
- const scope = payload?.scope === 'file' ? 'file' : 'workspace';
1013
- const filePath = payload?.filePath ?? null;
1014
-
1015
- if (scope === 'file') {
1016
- if (!isAllowedPath(filePath)) {
1017
- rejectPath(cb, filePath);
1018
- return;
1019
- }
1020
- socket.join(activityRoomForFile(filePath));
1021
- }
1022
-
1023
- socket.join(activityRoomForWorkspace());
1024
- respond(cb, { ok: true, scope, filePath: filePath ?? null });
1025
- });
1026
-
1027
- socket.on('collab:notify:preferences:get', async (_payload, cb) => {
1028
- touchPresence(socket.id);
1029
- try {
1030
- const preferences = await collabStore.getNotifyPreferences(user.id);
1031
- respond(cb, { ok: true, preferences });
1032
- } catch (err) {
1033
- respond(cb, { ok: false, error: err?.message ?? String(err) });
1034
- }
1035
- });
1036
-
1037
- socket.on('collab:notify:preferences:set', async (payload, cb) => {
1038
- touchPresence(socket.id);
1039
- const scope = payload?.scope;
1040
- const key = payload?.key ?? null;
1041
- const mode = payload?.mode;
1042
-
1043
- if (scope === 'file' && key && !isAllowedPath(key)) {
1044
- rejectPath(cb, key);
1045
- return;
1046
- }
1047
- if (scope === 'workspace' && typeof key !== 'string') {
1048
- respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'workspace scope requires key' });
1049
- return;
1050
- }
1051
-
1052
- try {
1053
- const preferences = await collabStore.setNotifyPreference({
1054
- userId: user.id,
1055
- scope,
1056
- key,
1057
- mode,
1058
- });
1059
-
1060
- respond(cb, { ok: true, preferences });
1061
- } catch (err) {
1062
- respond(cb, { ok: false, error: err?.message ?? String(err) });
1063
- }
1064
- });
1065
-
1066
252
  socket.on('disconnect', () => {
1067
253
  console.log(`[socket] Disconnected: ${user.username} (${socket.id})`);
1068
254
  const openFiles = socketToFiles.get(socket.id) ?? new Set();
@@ -1071,8 +257,16 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
1071
257
  socket.broadcast.emit('presence-file-closed', { relPath, user });
1072
258
  }
1073
259
  socketToFiles.delete(socket.id);
260
+
261
+ // Release all file claims held by this socket
262
+ for (const [relPath, claim] of claimedFiles) {
263
+ if (claim.socketId === socket.id) {
264
+ claimedFiles.delete(relPath);
265
+ io.emit('file-unclaimed', { relPath, userId: claim.id });
266
+ }
267
+ }
268
+
1074
269
  userBySocket.delete(socket.id);
1075
- presenceStateBySocket.delete(socket.id);
1076
270
  socket.broadcast.emit('user-left', { user });
1077
271
  });
1078
272
  });