@fyresmith/hive-server 2.3.1 → 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.
- package/README.md +54 -89
- package/cli/commands/env.js +80 -0
- package/cli/commands/root.js +91 -0
- package/cli/commands/service.js +112 -0
- package/cli/commands/tunnel.js +165 -0
- package/cli/core/app.js +57 -0
- package/cli/core/context.js +110 -0
- package/cli/flows/doctor.js +101 -0
- package/cli/flows/setup.js +142 -0
- package/cli/flows/system.js +170 -0
- package/cli/main.js +5 -926
- package/cli/tunnel.js +17 -1
- package/lib/adapterRegistry.js +152 -0
- package/lib/collabProtocol.js +25 -0
- package/lib/collabStore.js +448 -0
- package/lib/discordWebhook.js +81 -0
- package/lib/mentionUtils.js +13 -0
- package/lib/socketHandler.js +891 -38
- package/lib/vaultManager.js +220 -4
- package/lib/yjsServer.js +6 -1
- package/package.json +3 -3
package/lib/socketHandler.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
});
|