@fyresmith/hive-server 2.3.2 → 2.4.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,24 +1,46 @@
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';
3
8
 
4
9
  /**
5
- * presenceByFile: file path Set of socket IDs
10
+ * presenceByFile: file path -> Set of socket IDs
6
11
  * @type {Map<string, Set<string>>}
7
12
  */
8
13
  const presenceByFile = new Map();
9
14
 
10
15
  /**
11
- * socketToFiles: socket ID Set of file paths (for cleanup on disconnect)
16
+ * socketToFiles: socket ID -> Set of file paths
12
17
  * @type {Map<string, Set<string>>}
13
18
  */
14
19
  const socketToFiles = new Map();
15
20
 
16
21
  /**
17
- * userBySocket: socket ID user object
22
+ * userBySocket: socket ID -> user object
18
23
  * @type {Map<string, object>}
19
24
  */
20
25
  const userBySocket = new Map();
21
26
 
27
+ /**
28
+ * presenceStateBySocket: socket ID -> transient presence state
29
+ * @type {Map<string, { lastSeenAt: number, protocolVersion: number, activeFile: string|null, cursor: any, viewport: any }>}
30
+ */
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;
43
+
22
44
  function respond(cb, payload) {
23
45
  if (typeof cb === 'function') cb(payload);
24
46
  }
@@ -38,17 +60,243 @@ function normalizeHexColor(color) {
38
60
  return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
39
61
  }
40
62
 
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
+
41
289
  /**
42
290
  * Attach all Socket.IO event handlers.
43
291
  *
44
292
  * @param {import('socket.io').Server} io
45
- * @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
293
+ * @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
46
294
  * @param {(relPath: string, hash: string, excludeSocketId: string|null) => void} broadcastFileUpdated
47
295
  * @param {(relPath: string) => Promise<void>} forceCloseRoom
48
296
  */
49
297
  export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom) {
50
- // Auth middleware for every socket connection
51
298
  io.use(socketMiddleware);
299
+ ensureStaleSweep(io);
52
300
 
53
301
  io.on('connection', (socket) => {
54
302
  const user = socket.user;
@@ -56,14 +304,99 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
56
304
 
57
305
  userBySocket.set(socket.id, user);
58
306
  socketToFiles.set(socket.id, new Set());
307
+ touchPresence(socket.id);
59
308
 
60
- // Notify others this user joined
61
309
  socket.broadcast.emit('user-joined', { user });
62
310
 
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
+
63
394
  // -----------------------------------------------------------------------
64
- // vault-sync-request
395
+ // Legacy sync events (kept for v1 compatibility)
65
396
  // -----------------------------------------------------------------------
397
+
66
398
  socket.on('vault-sync-request', async (cb) => {
399
+ touchPresence(socket.id);
67
400
  try {
68
401
  const manifest = await vault.getManifest();
69
402
  respond(cb, { ok: true, manifest });
@@ -73,10 +406,8 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
73
406
  }
74
407
  });
75
408
 
76
- // -----------------------------------------------------------------------
77
- // file-read
78
- // -----------------------------------------------------------------------
79
409
  socket.on('file-read', async (relPath, cb) => {
410
+ touchPresence(socket.id);
80
411
  if (!isAllowedPath(relPath)) {
81
412
  rejectPath(cb, relPath);
82
413
  return;
@@ -91,21 +422,56 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
91
422
  }
92
423
  });
93
424
 
94
- // -----------------------------------------------------------------------
95
- // file-write
96
- // -----------------------------------------------------------------------
97
425
  socket.on('file-write', async (payload, cb) => {
426
+ touchPresence(socket.id);
98
427
  const relPath = payload?.relPath;
99
428
  const content = payload?.content;
429
+ const expectedHash = normalizeExpectedHash(payload?.expectedHash);
100
430
  if (!isAllowedPath(relPath) || typeof content !== 'string') {
101
431
  rejectPath(cb, relPath);
102
432
  return;
103
433
  }
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
+
104
442
  try {
105
- await vault.writeFile(relPath, content);
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
+ });
106
462
  const hash = vault.hashContent(content);
107
463
  socket.broadcast.emit('file-updated', { relPath, hash, user });
108
- respond(cb, { ok: true, hash });
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
+
109
475
  console.log(`[socket] file-write: ${relPath} by ${user.username}`);
110
476
  } catch (err) {
111
477
  console.error(`[socket] file-write error (${relPath}):`, err);
@@ -113,20 +479,38 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
113
479
  }
114
480
  });
115
481
 
116
- // -----------------------------------------------------------------------
117
- // file-create
118
- // -----------------------------------------------------------------------
119
482
  socket.on('file-create', async (payload, cb) => {
483
+ touchPresence(socket.id);
120
484
  const relPath = payload?.relPath;
121
485
  const content = payload?.content;
122
486
  if (!isAllowedPath(relPath) || typeof content !== 'string') {
123
487
  rejectPath(cb, relPath);
124
488
  return;
125
489
  }
490
+ if (!normalizeAdapterContent(relPath, content, cb)) {
491
+ return;
492
+ }
493
+
126
494
  try {
127
- await vault.writeFile(relPath, content);
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);
128
503
  io.emit('file-created', { relPath, user });
129
- respond(cb, { ok: true });
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
+
130
514
  console.log(`[socket] file-create: ${relPath} by ${user.username}`);
131
515
  } catch (err) {
132
516
  console.error(`[socket] file-create error (${relPath}):`, err);
@@ -134,23 +518,35 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
134
518
  }
135
519
  });
136
520
 
137
- // -----------------------------------------------------------------------
138
- // file-delete
139
- // -----------------------------------------------------------------------
140
521
  socket.on('file-delete', async (relPath, cb) => {
522
+ touchPresence(socket.id);
141
523
  if (!isAllowedPath(relPath)) {
142
524
  rejectPath(cb, relPath);
143
525
  return;
144
526
  }
145
527
  try {
146
- // Force-close active Yjs room first
147
528
  const docName = encodeURIComponent(relPath);
148
529
  if (getActiveRooms().has(docName)) {
149
530
  await forceCloseRoom(relPath);
150
531
  }
151
- await vault.deleteFile(relPath);
532
+ await vault.deleteFile(relPath, {
533
+ history: {
534
+ action: 'delete',
535
+ source: 'socket',
536
+ actor: actorFromUser(user),
537
+ },
538
+ });
152
539
  io.emit('file-deleted', { relPath, user });
153
540
  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
+
154
550
  console.log(`[socket] file-delete: ${relPath} by ${user.username}`);
155
551
  } catch (err) {
156
552
  console.error(`[socket] file-delete error (${relPath}):`, err);
@@ -158,10 +554,8 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
158
554
  }
159
555
  });
160
556
 
161
- // -----------------------------------------------------------------------
162
- // file-rename
163
- // -----------------------------------------------------------------------
164
557
  socket.on('file-rename', async (payload, cb) => {
558
+ touchPresence(socket.id);
165
559
  const oldPath = payload?.oldPath;
166
560
  const newPath = payload?.newPath;
167
561
  if (!isAllowedPath(oldPath) || !isAllowedPath(newPath)) {
@@ -169,48 +563,506 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
169
563
  return;
170
564
  }
171
565
  try {
172
- // Force-close active Yjs room for old path
173
566
  const docName = encodeURIComponent(oldPath);
174
567
  if (getActiveRooms().has(docName)) {
175
568
  await forceCloseRoom(oldPath);
176
569
  }
177
- await vault.renameFile(oldPath, newPath);
570
+ await vault.renameFile(oldPath, newPath, {
571
+ history: {
572
+ source: 'socket',
573
+ actor: actorFromUser(user),
574
+ },
575
+ });
178
576
  io.emit('file-renamed', { oldPath, newPath, user });
179
577
  respond(cb, { ok: true });
180
- console.log(`[socket] file-rename: ${oldPath} → ${newPath} by ${user.username}`);
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}`);
181
588
  } catch (err) {
182
589
  console.error(`[socket] file-rename error (${oldPath}):`, err);
183
590
  respond(cb, { ok: false, error: err.message });
184
591
  }
185
592
  });
186
593
 
187
- // -----------------------------------------------------------------------
188
- // presence-file-opened
189
- // -----------------------------------------------------------------------
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
+ }
609
+ });
610
+
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
+ }
630
+ });
631
+
190
632
  socket.on('presence-file-opened', (payload) => {
191
633
  const relPath = typeof payload === 'string' ? payload : payload?.relPath;
192
634
  const color = normalizeHexColor(typeof payload === 'string' ? null : payload?.color);
193
635
  if (!isAllowedPath(relPath)) return;
636
+
194
637
  if (!presenceByFile.has(relPath)) presenceByFile.set(relPath, new Set());
195
638
  presenceByFile.get(relPath).add(socket.id);
196
639
  socketToFiles.get(socket.id)?.add(relPath);
640
+
641
+ touchPresence(socket.id, { activeFile: relPath });
197
642
  const presenceUser = color ? { ...user, color } : user;
198
643
  socket.broadcast.emit('presence-file-opened', { relPath, user: presenceUser });
199
644
  });
200
645
 
201
- // -----------------------------------------------------------------------
202
- // presence-file-closed
203
- // -----------------------------------------------------------------------
204
646
  socket.on('presence-file-closed', (relPath) => {
205
647
  if (!isAllowedPath(relPath)) return;
206
648
  presenceByFile.get(relPath)?.delete(socket.id);
207
649
  socketToFiles.get(socket.id)?.delete(relPath);
650
+
651
+ const state = touchPresence(socket.id);
652
+ if (state.activeFile === relPath) {
653
+ state.activeFile = null;
654
+ }
655
+
208
656
  socket.broadcast.emit('presence-file-closed', { relPath, user });
209
657
  });
210
658
 
211
659
  // -----------------------------------------------------------------------
212
- // disconnect
660
+ // Collaboration artifacts / activity / notifications
213
661
  // -----------------------------------------------------------------------
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
+
214
1066
  socket.on('disconnect', () => {
215
1067
  console.log(`[socket] Disconnected: ${user.username} (${socket.id})`);
216
1068
  const openFiles = socketToFiles.get(socket.id) ?? new Set();
@@ -220,6 +1072,7 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
220
1072
  }
221
1073
  socketToFiles.delete(socket.id);
222
1074
  userBySocket.delete(socket.id);
1075
+ presenceStateBySocket.delete(socket.id);
223
1076
  socket.broadcast.emit('user-left', { user });
224
1077
  });
225
1078
  });