clay-server 4.3.0-beta.1 → 4.3.0-beta.10
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/lib/driver-continuation-pair.js +5 -1
- package/lib/multi-worker-feature.js +11 -0
- package/lib/project-connection.js +3 -1
- package/lib/project-message-delivery.js +20 -0
- package/lib/project-pair-autonomous-stop.js +14 -8
- package/lib/project-pair-close-transaction.js +59 -0
- package/lib/project-pair-global-stop.js +18 -0
- package/lib/project-pair-lifecycle-status.js +34 -0
- package/lib/project-pair-lifecycle.js +58 -76
- package/lib/project-pair-message.js +1 -1
- package/lib/project-pair-owned-stop.js +7 -4
- package/lib/project-pair-result-capture.js +116 -0
- package/lib/project-pair-result-delivery.js +140 -0
- package/lib/project-pair-result-outbox.js +400 -0
- package/lib/project-pair-result-pipeline.js +16 -0
- package/lib/project-pair-result-recovery.js +229 -0
- package/lib/project-pair-structural-close.js +20 -0
- package/lib/project-pair-task-control.js +45 -18
- package/lib/project-pair-worker-close.js +68 -0
- package/lib/project-session-pair-prompt.js +26 -0
- package/lib/project-session-pair.js +87 -106
- package/lib/project-sessions.js +4 -0
- package/lib/project-user-message.js +7 -0
- package/lib/project-worker-creation-proposal.js +176 -0
- package/lib/project-worker-permission.js +7 -8
- package/lib/project-worker-proposal-runner.js +45 -0
- package/lib/project-worker-proposal.js +79 -115
- package/lib/project.js +38 -1
- package/lib/public/app.js +1 -0
- package/lib/public/css/messages.css +12 -0
- package/lib/public/css/mobile-nav.css +29 -14
- package/lib/public/css/overlays.css +22 -21
- package/lib/public/css/pane.css +64 -14
- package/lib/public/css/sidebar.css +62 -7
- package/lib/public/index.html +3 -3
- package/lib/public/modules/app-connection.js +165 -74
- package/lib/public/modules/app-messages.js +17 -3
- package/lib/public/modules/message-delivery-ui.js +63 -0
- package/lib/public/modules/message-delivery.js +163 -6
- package/lib/public/modules/pair-result-status.js +87 -0
- package/lib/public/modules/permission-control.js +5 -5
- package/lib/public/modules/project-activation.js +10 -7
- package/lib/public/modules/session-creation-cta.js +46 -0
- package/lib/public/modules/sidebar-mobile.js +42 -30
- package/lib/public/modules/sidebar-sessions.js +77 -52
- package/lib/public/modules/split-group-helpers.js +74 -2
- package/lib/public/modules/split-pair-ui.js +28 -6
- package/lib/public/modules/split-pane-reconciler.js +28 -0
- package/lib/public/modules/split-pane-renderer.js +107 -0
- package/lib/public/modules/split-view.js +58 -188
- package/lib/public/modules/websocket-lifecycle.js +178 -0
- package/lib/public/modules/websocket-watchdog.js +126 -0
- package/lib/public/modules/worker-pane-lock.js +13 -3
- package/lib/server.js +1 -0
- package/lib/session-pair-factory.js +66 -0
- package/lib/session-pair-mcp-server.js +10 -5
- package/lib/session-pair-prompts.js +41 -25
- package/lib/session-pair-target.js +42 -0
- package/lib/session-pair-turn-control.js +11 -6
- package/lib/session-split-group-anchors.js +89 -7
- package/lib/session-split-group-persistence.js +136 -0
- package/lib/session-split-group-roles.js +164 -0
- package/lib/session-split-group-v2-store.js +146 -0
- package/lib/session-split-groups.js +109 -99
- package/lib/sessions.js +70 -21
- package/lib/ws-schema.js +2 -0
- package/package.json +1 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
var crypto = require("crypto");
|
|
2
|
-
var fs = require("fs");
|
|
3
|
-
var path = require("path");
|
|
4
2
|
var durableAnchors = require("./session-split-group-anchors");
|
|
3
|
+
var splitRoles = require("./session-split-group-roles");
|
|
4
|
+
var versionedStore = require("./session-split-group-v2-store");
|
|
5
|
+
var multiWorkerFeature = require("./multi-worker-feature");
|
|
6
|
+
var groupPersistence = require("./session-split-group-persistence");
|
|
5
7
|
function truncateTitle(title) {
|
|
6
8
|
var value = String(title || "New Session");
|
|
7
9
|
return value.length > 20 ? value.slice(0, 19) + "…" : value;
|
|
@@ -11,92 +13,14 @@ function autoGroupName(leftTitle, rightTitle) {
|
|
|
11
13
|
}
|
|
12
14
|
function createSplitGroupStore(opts) {
|
|
13
15
|
var sessions = opts.sessions;
|
|
14
|
-
var groupsFile = path.join(opts.sessionsDir, "split-groups.json");
|
|
15
16
|
var usersModule = opts.usersModule;
|
|
16
17
|
var broadcast = opts.broadcast || function () {};
|
|
17
18
|
var onPairChanged = opts.onPairChanged || function () {};
|
|
18
19
|
var groups = [];
|
|
19
20
|
var stagedTransfers = [];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
group.pairCliIds = anchors.cli;
|
|
24
|
-
group.pairOriginIds = anchors.origin;
|
|
25
|
-
}
|
|
26
|
-
function save() {
|
|
27
|
-
for (var gi = 0; gi < groups.length; gi++) {
|
|
28
|
-
var anchors = durableAnchors.memberAnchors(groups[gi], sessions);
|
|
29
|
-
groups[gi].memberCliIds = anchors.cli;
|
|
30
|
-
groups[gi].memberOriginIds = anchors.origin;
|
|
31
|
-
refreshPairAnchors(groups[gi]);
|
|
32
|
-
}
|
|
33
|
-
var serialized = JSON.stringify(groups, null, 2) + "\n";
|
|
34
|
-
if (typeof opts.persistGroups === "function") {
|
|
35
|
-
if (opts.persistGroups(groupsFile, serialized) === false) throw new Error("Split group persistence returned false");
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
var tmp = groupsFile + ".tmp." + process.pid;
|
|
39
|
-
fs.mkdirSync(opts.sessionsDir, { recursive: true });
|
|
40
|
-
fs.writeFileSync(tmp, serialized);
|
|
41
|
-
if (process.platform !== "win32") {
|
|
42
|
-
try { fs.chmodSync(tmp, 0o600); } catch (e) {}
|
|
43
|
-
}
|
|
44
|
-
fs.renameSync(tmp, groupsFile);
|
|
45
|
-
}
|
|
46
|
-
function validRecord(group) {
|
|
47
|
-
return group && typeof group.id === "string" && Array.isArray(group.members) &&
|
|
48
|
-
group.members.length === 2 && group.members[0] !== group.members[1] &&
|
|
49
|
-
Number.isInteger(group.members[0]) && Number.isInteger(group.members[1]) &&
|
|
50
|
-
sessions.has(group.members[0]) && sessions.has(group.members[1]);
|
|
51
|
-
}
|
|
52
|
-
function load() {
|
|
53
|
-
var loaded = [];
|
|
54
|
-
var shouldRewrite = false;
|
|
55
|
-
try {
|
|
56
|
-
loaded = JSON.parse(fs.readFileSync(groupsFile, "utf8"));
|
|
57
|
-
if (!Array.isArray(loaded)) loaded = [];
|
|
58
|
-
} catch (e) {
|
|
59
|
-
if (e.code !== "ENOENT") shouldRewrite = true;
|
|
60
|
-
}
|
|
61
|
-
var claimed = {};
|
|
62
|
-
groups = loaded.filter(function (group) {
|
|
63
|
-
// Remap members from the durable cliSessionIds; the stored localIds
|
|
64
|
-
// are only trusted for legacy records that predate memberCliIds.
|
|
65
|
-
if (group && (Array.isArray(group.memberCliIds) || Array.isArray(group.memberOriginIds))) {
|
|
66
|
-
var memberCliIds = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
|
|
67
|
-
var memberOriginIds = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
|
|
68
|
-
var left = durableAnchors.findSession(sessions, memberCliIds[0], memberOriginIds[0]);
|
|
69
|
-
var right = durableAnchors.findSession(sessions, memberCliIds[1], memberOriginIds[1]);
|
|
70
|
-
if (!left || !right || left === right) return false;
|
|
71
|
-
if (group.members[0] !== left.localId || group.members[1] !== right.localId) {
|
|
72
|
-
group.members = [left.localId, right.localId];
|
|
73
|
-
shouldRewrite = true;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
if (group && group.pair && (Array.isArray(group.pairCliIds) || Array.isArray(group.pairOriginIds))) {
|
|
77
|
-
var pairCliIds = Array.isArray(group.pairCliIds) ? group.pairCliIds : [null, null];
|
|
78
|
-
var pairOriginIds = Array.isArray(group.pairOriginIds) ? group.pairOriginIds : [null, null];
|
|
79
|
-
var driver = durableAnchors.findSession(sessions, pairCliIds[0], pairOriginIds[0]);
|
|
80
|
-
var worker = durableAnchors.findSession(sessions, pairCliIds[1], pairOriginIds[1]);
|
|
81
|
-
if (driver && worker) {
|
|
82
|
-
group.pair.driverId = driver.localId;
|
|
83
|
-
group.pair.workerId = worker.localId;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (group && group.pair) {
|
|
87
|
-
var pairValid = Array.isArray(group.members) && group.pair.driverId !== group.pair.workerId &&
|
|
88
|
-
group.members.indexOf(group.pair.driverId) !== -1 &&
|
|
89
|
-
group.members.indexOf(group.pair.workerId) !== -1;
|
|
90
|
-
if (!pairValid) return false;
|
|
91
|
-
}
|
|
92
|
-
if (!validRecord(group) || claimed[group.members[0]] || claimed[group.members[1]]) return false;
|
|
93
|
-
claimed[group.members[0]] = true;
|
|
94
|
-
claimed[group.members[1]] = true;
|
|
95
|
-
return true;
|
|
96
|
-
});
|
|
97
|
-
if (groups.length !== loaded.length) shouldRewrite = true;
|
|
98
|
-
if (shouldRewrite) save();
|
|
99
|
-
}
|
|
21
|
+
var versionedEnabled = multiWorkerFeature.isEnabled(opts.multiWorkerFeature);
|
|
22
|
+
var persistence = groupPersistence.attachPersistence(Object.assign({}, opts, { versionedEnabled: versionedEnabled }));
|
|
23
|
+
function save() { return persistence.save(groups); }
|
|
100
24
|
function isMultiUser() {
|
|
101
25
|
return !!(usersModule && usersModule.isMultiUser && usersModule.isMultiUser());
|
|
102
26
|
}
|
|
@@ -153,6 +77,8 @@ function createSplitGroupStore(opts) {
|
|
|
153
77
|
if (!left || !right) return { ok: false, error: "Session not found" };
|
|
154
78
|
if (!canAccess(ws, left) || !canAccess(ws, right)) return { ok: false, error: "Session access denied" };
|
|
155
79
|
if (membershipGroup(members[0]) || membershipGroup(members[1])) return { ok: false, error: "A session can belong to only one split group" };
|
|
80
|
+
var roleCheck = splitRoles.validateStorePair(msg.pair, members);
|
|
81
|
+
if (!roleCheck.ok) return { ok: false, error: roleCheck.error };
|
|
156
82
|
var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
|
|
157
83
|
var group = {
|
|
158
84
|
id: "sg_" + Date.now().toString(36) + "_" + crypto.randomBytes(3).toString("hex"),
|
|
@@ -183,7 +109,8 @@ function createSplitGroupStore(opts) {
|
|
|
183
109
|
if ((left.ownerId || null) !== (ownerId || null) || (right.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Session owner does not match the scheduled task owner" };
|
|
184
110
|
if (membershipGroup(left.localId) || membershipGroup(right.localId)) return { ok: false, error: "A session can belong to only one split group" };
|
|
185
111
|
var pair = msg && msg.pair;
|
|
186
|
-
|
|
112
|
+
var roleCheck = splitRoles.validateStorePair(pair, members);
|
|
113
|
+
if (!roleCheck.ok || !pair || pair.driverId !== left.localId || pair.workerId !== right.localId) return { ok: false, error: roleCheck.error || "Pair roles must reference both split group members" };
|
|
187
114
|
var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
|
|
188
115
|
var group = {
|
|
189
116
|
id: "sg_" + Date.now().toString(36) + "_" + crypto.randomBytes(3).toString("hex"),
|
|
@@ -206,12 +133,13 @@ function createSplitGroupStore(opts) {
|
|
|
206
133
|
onPairChanged(removed);
|
|
207
134
|
return true;
|
|
208
135
|
}
|
|
209
|
-
// Runtime pair lookup sees this isolated projection while ordinary list,
|
|
210
|
-
// save, anchor refresh, broadcast, and unrelated mutations keep using the
|
|
211
|
-
// durable source-owned record until commit.
|
|
212
136
|
function beginOwnedDriverTransfer(ownerId, msg) {
|
|
213
137
|
var group = groups.find(function (item) { return item.id === (msg && msg.id); });
|
|
214
138
|
if (!group || (group.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Split group not found" };
|
|
139
|
+
var transferRoles = splitRoles.normalizePair(group.pair, group.members);
|
|
140
|
+
if (transferRoles.ok && transferRoles.kind === "versioned") {
|
|
141
|
+
return { ok: false, error: "Version 2 Driver transfer is not enabled" };
|
|
142
|
+
}
|
|
215
143
|
if (!group.pair || group.pair.driverId !== msg.sourceDriverId || group.pair.workerId !== msg.workerId) {
|
|
216
144
|
return { ok: false, error: "The split pair changed before Driver transfer" };
|
|
217
145
|
}
|
|
@@ -391,6 +319,8 @@ function createSplitGroupStore(opts) {
|
|
|
391
319
|
return { ok: false, error: "Driver must be a member of the split group" };
|
|
392
320
|
}
|
|
393
321
|
var workerId = group.members[0] === msg.driverId ? group.members[1] : group.members[0];
|
|
322
|
+
var roleCheck = splitRoles.validateStorePair({ driverId: msg.driverId, workerId: workerId }, group.members);
|
|
323
|
+
if (!roleCheck.ok) return { ok: false, error: roleCheck.error };
|
|
394
324
|
group.pair = { driverId: msg.driverId, workerId: workerId };
|
|
395
325
|
save();
|
|
396
326
|
broadcast();
|
|
@@ -398,22 +328,57 @@ function createSplitGroupStore(opts) {
|
|
|
398
328
|
return { ok: true, group: group };
|
|
399
329
|
}
|
|
400
330
|
|
|
401
|
-
function dissolve(ws, msg) {
|
|
331
|
+
function dissolve(ws, msg, hooks) {
|
|
402
332
|
var index = groups.findIndex(function (item) { return item.id === (msg && msg.id); });
|
|
403
333
|
if (index === -1) return { ok: false, error: "Split group not found" };
|
|
404
334
|
if (!canOwn(ws, groups[index])) return { ok: false, error: "Only the group owner can separate it" };
|
|
405
335
|
var removed = groups.splice(index, 1)[0];
|
|
406
|
-
save();
|
|
336
|
+
try { save(); }
|
|
337
|
+
catch (error) { groups.splice(index, 0, removed); return { ok: false, error: error.message || String(error) }; }
|
|
338
|
+
if (hooks && typeof hooks.afterPersist === "function") {
|
|
339
|
+
groups.splice(index, 0, removed);
|
|
340
|
+
var accepted;
|
|
341
|
+
try { accepted = hooks.afterPersist(); }
|
|
342
|
+
catch (error) { accepted = { ok: false, error: error.message || String(error) }; }
|
|
343
|
+
if (!accepted || accepted.ok === false) {
|
|
344
|
+
try { save(); }
|
|
345
|
+
catch (rollbackError) {
|
|
346
|
+
return { ok: false, error: (accepted && accepted.error || "close transaction rejected") +
|
|
347
|
+
"; persistence rollback failed: " + (rollbackError.message || String(rollbackError)) };
|
|
348
|
+
}
|
|
349
|
+
return { ok: false, error: accepted && accepted.error || "close transaction rejected" };
|
|
350
|
+
}
|
|
351
|
+
groups.splice(index, 1);
|
|
352
|
+
}
|
|
407
353
|
broadcast();
|
|
408
354
|
return { ok: true, group: removed };
|
|
409
355
|
}
|
|
410
356
|
|
|
411
357
|
function dissolveBySession(localId) {
|
|
412
|
-
var
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
358
|
+
var index = groups.findIndex(function (group) { return group.members.indexOf(localId) !== -1; });
|
|
359
|
+
if (index === -1) return false;
|
|
360
|
+
var group = groups[index];
|
|
361
|
+
var normalized = splitRoles.normalizePair(group.pair, group.members);
|
|
362
|
+
var beforeMembers = group.members.slice();
|
|
363
|
+
var beforePair = group.pair && Object.assign({}, group.pair);
|
|
364
|
+
if (beforePair && Array.isArray(beforePair.workerIds)) beforePair.workerIds = beforePair.workerIds.slice();
|
|
365
|
+
var removed = null;
|
|
366
|
+
if (normalized.ok && normalized.kind === "versioned" && normalized.driverId !== localId &&
|
|
367
|
+
normalized.workerIds.length > 1) {
|
|
368
|
+
var remaining = normalized.workerIds.filter(function (id) { return id !== localId; });
|
|
369
|
+
group.members = [normalized.driverId].concat(remaining);
|
|
370
|
+
group.pair = { version: 2, driverId: normalized.driverId, workerIds: remaining };
|
|
371
|
+
} else {
|
|
372
|
+
removed = groups.splice(index, 1)[0];
|
|
373
|
+
}
|
|
374
|
+
try { save(); }
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (removed) groups.splice(index, 0, removed);
|
|
377
|
+
else { group.members = beforeMembers; group.pair = beforePair; }
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
416
380
|
broadcast();
|
|
381
|
+
onPairChanged(group);
|
|
417
382
|
return true;
|
|
418
383
|
}
|
|
419
384
|
|
|
@@ -421,6 +386,11 @@ function createSplitGroupStore(opts) {
|
|
|
421
386
|
var group = groupForMember(localId);
|
|
422
387
|
if (!group) return false;
|
|
423
388
|
var fresh = durableAnchors.memberAnchors(group, sessions);
|
|
389
|
+
if (fresh && fresh.version === 2) {
|
|
390
|
+
if (JSON.stringify(fresh) === JSON.stringify(group.memberAnchors || null)) return false;
|
|
391
|
+
save();
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
424
394
|
var prevCli = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
|
|
425
395
|
var prevOrigin = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
|
|
426
396
|
if (fresh.cli[0] === prevCli[0] && fresh.cli[1] === prevCli[1] &&
|
|
@@ -445,11 +415,54 @@ function createSplitGroupStore(opts) {
|
|
|
445
415
|
return true;
|
|
446
416
|
}
|
|
447
417
|
|
|
448
|
-
|
|
418
|
+
function removeVersionedGroup(group, hooks) {
|
|
419
|
+
var index = groups.indexOf(group);
|
|
420
|
+
if (index === -1) return { ok: false, error: "Split group not found" };
|
|
421
|
+
groups.splice(index, 1);
|
|
422
|
+
try { save(); }
|
|
423
|
+
catch (error) {
|
|
424
|
+
groups.splice(index, 0, group);
|
|
425
|
+
return { ok: false, error: error.message || String(error) };
|
|
426
|
+
}
|
|
427
|
+
if (hooks && typeof hooks.afterPersist === "function") {
|
|
428
|
+
groups.splice(index, 0, group);
|
|
429
|
+
var accepted;
|
|
430
|
+
try { accepted = hooks.afterPersist(); }
|
|
431
|
+
catch (error) { accepted = { ok: false, error: error.message || String(error) }; }
|
|
432
|
+
if (!accepted || accepted.ok === false) {
|
|
433
|
+
try { save(); }
|
|
434
|
+
catch (rollbackError) {
|
|
435
|
+
return { ok: false, error: (accepted && accepted.error || "close transaction rejected") +
|
|
436
|
+
"; persistence rollback failed: " + (rollbackError.message || String(rollbackError)) };
|
|
437
|
+
}
|
|
438
|
+
return { ok: false, error: accepted && accepted.error || "close transaction rejected" };
|
|
439
|
+
}
|
|
440
|
+
groups.splice(index, 1);
|
|
441
|
+
}
|
|
442
|
+
broadcast();
|
|
443
|
+
onPairChanged(group);
|
|
444
|
+
return { ok: true, group: group };
|
|
445
|
+
}
|
|
446
|
+
function versionedChanged(group) {
|
|
447
|
+
broadcast();
|
|
448
|
+
onPairChanged(group);
|
|
449
|
+
}
|
|
450
|
+
var versionedMutations = versionedStore.attachVersionedMutations({
|
|
451
|
+
sessions: sessions,
|
|
452
|
+
enabled: versionedEnabled,
|
|
453
|
+
groups: function () { return groups; },
|
|
454
|
+
membershipGroup: membershipGroup,
|
|
455
|
+
save: save,
|
|
456
|
+
remove: removeVersionedGroup,
|
|
457
|
+
changed: versionedChanged,
|
|
458
|
+
});
|
|
459
|
+
groups = persistence.load();
|
|
449
460
|
return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned,
|
|
450
461
|
beginOwnedDriverTransfer: beginOwnedDriverTransfer, commitOwnedDriverTransfer: commitOwnedDriverTransfer,
|
|
451
462
|
rollbackOwnedDriverTransfer: rollbackOwnedDriverTransfer,
|
|
452
463
|
rename: rename, setPair: setPair, dissolve: dissolve, dissolveBySession: dissolveBySession,
|
|
464
|
+
addWorker: versionedMutations.addWorker, replaceWorker: versionedMutations.replaceWorker,
|
|
465
|
+
removeWorker: versionedMutations.removeWorker,
|
|
453
466
|
refreshAutoName: refreshAutoName, refreshAnchors: refreshAnchors,
|
|
454
467
|
listFor: listFor, groupForMember: groupForMember, get groups() { return groups; } };
|
|
455
468
|
}
|
|
@@ -461,13 +474,11 @@ function attachSplitGroups(ctx) {
|
|
|
461
474
|
usersModule: ctx.usersModule,
|
|
462
475
|
broadcast: broadcast,
|
|
463
476
|
onPairChanged: ctx.onPairChanged,
|
|
477
|
+
multiWorkerFeature: ctx.multiWorkerFeature,
|
|
464
478
|
});
|
|
465
479
|
|
|
466
480
|
function sendState(ws) {
|
|
467
481
|
if (!ws) return;
|
|
468
|
-
// Pane clients need the same owner-scoped pair roles as the parent shell.
|
|
469
|
-
// The Worker composer lock resolves its role from this projection; hiding
|
|
470
|
-
// the projection from panes leaves a configured Worker looking editable.
|
|
471
482
|
ctx.sendTo(ws, { type: "split_groups", groups: store.listFor(ws) });
|
|
472
483
|
}
|
|
473
484
|
|
|
@@ -495,5 +506,4 @@ function attachSplitGroups(ctx) {
|
|
|
495
506
|
return { handleMessage: handleMessage, sendConnectionState: sendState, store: store };
|
|
496
507
|
}
|
|
497
508
|
|
|
498
|
-
module.exports = { attachSplitGroups: attachSplitGroups, createSplitGroupStore: createSplitGroupStore,
|
|
499
|
-
autoGroupName: autoGroupName };
|
|
509
|
+
module.exports = { attachSplitGroups: attachSplitGroups, createSplitGroupStore: createSplitGroupStore, autoGroupName: autoGroupName };
|
package/lib/sessions.js
CHANGED
|
@@ -144,17 +144,32 @@ function createSessionManager(opts) {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
function sessionFilePath(
|
|
148
|
-
return path.join(sessionsDir,
|
|
147
|
+
function sessionFilePath(recordId) {
|
|
148
|
+
return path.join(sessionsDir, recordId + ".jsonl");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A Clay record must exist before a provider assigns its resumable id.
|
|
152
|
+
// Existing records keep their filename; fresh provider-less sessions use
|
|
153
|
+
// the already durable origin id and later store cliSessionId as metadata.
|
|
154
|
+
function sessionRecordId(session) {
|
|
155
|
+
if (!session) return null;
|
|
156
|
+
return session._sessionRecordId || session.cliSessionId || session.sessionOriginId || null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function existingSessionRecordId(session) {
|
|
160
|
+
if (!session) return null;
|
|
161
|
+
return session._sessionRecordId || session.cliSessionId || null;
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
function saveSessionFile(session) {
|
|
152
|
-
if (!session.cliSessionId) return false;
|
|
153
165
|
try {
|
|
166
|
+
var provenanceMeta = sessionProvenance.metadata(session);
|
|
167
|
+
var recordId = sessionRecordId(session);
|
|
168
|
+
if (!recordId) return false;
|
|
154
169
|
var metaObj = {
|
|
155
170
|
type: "meta",
|
|
156
171
|
localId: session.localId,
|
|
157
|
-
cliSessionId: session.cliSessionId,
|
|
172
|
+
cliSessionId: session.cliSessionId || null,
|
|
158
173
|
title: session.title,
|
|
159
174
|
titleProvisional: session.titleProvisional === true,
|
|
160
175
|
titleAutoGenerated: session.titleAutoGenerated === true,
|
|
@@ -204,7 +219,6 @@ function createSessionManager(opts) {
|
|
|
204
219
|
if (session.homeDebateInitialTopic) metaObj.homeDebateInitialTopic = session.homeDebateInitialTopic;
|
|
205
220
|
if (session.homeClayEntryRequestId) metaObj.homeClayEntryRequestId = session.homeClayEntryRequestId;
|
|
206
221
|
if (session.homeClayEntryMode) metaObj.homeClayEntryMode = session.homeClayEntryMode;
|
|
207
|
-
var provenanceMeta = sessionProvenance.metadata(session);
|
|
208
222
|
metaObj.sessionOriginId = provenanceMeta.sessionOriginId;
|
|
209
223
|
if (provenanceMeta.sessionProvenance) metaObj.sessionProvenance = provenanceMeta.sessionProvenance;
|
|
210
224
|
if (Array.isArray(session._workerGenerations)) metaObj.workerGenerations = session._workerGenerations;
|
|
@@ -213,7 +227,7 @@ function createSessionManager(opts) {
|
|
|
213
227
|
for (var i = 0; i < session.history.length; i++) {
|
|
214
228
|
lines.push(JSON.stringify(session.history[i]));
|
|
215
229
|
}
|
|
216
|
-
var sfPath = sessionFilePath(
|
|
230
|
+
var sfPath = sessionFilePath(recordId);
|
|
217
231
|
// Atomic write: write to temp file then rename, so a crash mid-write
|
|
218
232
|
// cannot leave a truncated/corrupted session file.
|
|
219
233
|
var tmpPath = sfPath + ".tmp." + process.pid;
|
|
@@ -222,6 +236,7 @@ function createSessionManager(opts) {
|
|
|
222
236
|
try { fs.chmodSync(tmpPath, 0o600); } catch (chmodErr) {}
|
|
223
237
|
}
|
|
224
238
|
fs.renameSync(tmpPath, sfPath);
|
|
239
|
+
session._sessionRecordId = recordId;
|
|
225
240
|
return true;
|
|
226
241
|
} catch(e) {
|
|
227
242
|
console.error("[session] Failed to save session file:", e.message);
|
|
@@ -230,16 +245,19 @@ function createSessionManager(opts) {
|
|
|
230
245
|
}
|
|
231
246
|
|
|
232
247
|
function appendToSessionFile(session, obj) {
|
|
233
|
-
|
|
248
|
+
var recordId = existingSessionRecordId(session);
|
|
249
|
+
if (!recordId) return true;
|
|
234
250
|
session.lastActivity = Date.now();
|
|
235
251
|
try {
|
|
236
|
-
var afPath = sessionFilePath(
|
|
252
|
+
var afPath = sessionFilePath(recordId);
|
|
237
253
|
fs.appendFileSync(afPath, JSON.stringify(obj) + "\n");
|
|
238
254
|
if (process.platform !== "win32") {
|
|
239
255
|
try { fs.chmodSync(afPath, 0o600); } catch (chmodErr) {}
|
|
240
256
|
}
|
|
257
|
+
return true;
|
|
241
258
|
} catch(e) {
|
|
242
259
|
console.error("[session] Failed to append to session file:", e.message);
|
|
260
|
+
return false;
|
|
243
261
|
}
|
|
244
262
|
}
|
|
245
263
|
|
|
@@ -274,7 +292,8 @@ function createSessionManager(opts) {
|
|
|
274
292
|
|
|
275
293
|
var meta;
|
|
276
294
|
try { meta = JSON.parse(lines[0]); } catch { continue; }
|
|
277
|
-
|
|
295
|
+
var pendingOrigin = typeof meta.sessionOriginId === "string" && meta.sessionOriginId.trim() && meta.sessionOriginId.length <= 128;
|
|
296
|
+
if (meta.type !== "meta" || (!meta.cliSessionId && !pendingOrigin)) continue;
|
|
278
297
|
|
|
279
298
|
var history = [];
|
|
280
299
|
for (var j = 1; j < lines.length; j++) {
|
|
@@ -283,7 +302,7 @@ function createSessionManager(opts) {
|
|
|
283
302
|
|
|
284
303
|
var fileMtime = 0;
|
|
285
304
|
try { fileMtime = fs.statSync(path.join(sessionsDir, files[i])).mtimeMs; } catch {}
|
|
286
|
-
loaded.push({ meta: meta, history: history, mtime: fileMtime });
|
|
305
|
+
loaded.push({ meta: meta, history: history, mtime: fileMtime, recordId: files[i].slice(0, -".jsonl".length) });
|
|
287
306
|
}
|
|
288
307
|
|
|
289
308
|
loaded.sort(function(a, b) { return a.meta.createdAt - b.meta.createdAt; });
|
|
@@ -308,6 +327,7 @@ function createSessionManager(opts) {
|
|
|
308
327
|
queryInstance: null,
|
|
309
328
|
messageQueue: null,
|
|
310
329
|
cliSessionId: m.cliSessionId,
|
|
330
|
+
_sessionRecordId: loaded[i].recordId,
|
|
311
331
|
blocks: {},
|
|
312
332
|
sentToolResults: {},
|
|
313
333
|
pendingPermissions: {},
|
|
@@ -386,7 +406,7 @@ function createSessionManager(opts) {
|
|
|
386
406
|
saveSessionFile(session);
|
|
387
407
|
try {
|
|
388
408
|
var preservedTime = new Date(activityTime);
|
|
389
|
-
fs.utimesSync(sessionFilePath(session
|
|
409
|
+
fs.utimesSync(sessionFilePath(sessionRecordId(session)), preservedTime, preservedTime);
|
|
390
410
|
} catch (e) {}
|
|
391
411
|
delete session._provenanceMigrationNeeded;
|
|
392
412
|
});
|
|
@@ -846,7 +866,7 @@ function createSessionManager(opts) {
|
|
|
846
866
|
var _sessionVendor = session.vendor || defaultVendor || "claude";
|
|
847
867
|
var _vendorCaps = _capsByVendor[_sessionVendor] || {};
|
|
848
868
|
var _permissionState = require("./session-permission-mode").clientState(session, "default", defaultVendor);
|
|
849
|
-
_send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: _permissionState.requestedPermissionMode, effectivePermissionMode: _permissionState.effectivePermissionMode, permissionCapabilities: _permissionState.permissionCapabilities, mcpPermissionModeOverrides: _permissionState.mcpPermissionModeOverrides, autonomousRun: projectAutonomousRun(session.autonomousRun), loopInterviewBrief: session.loopInterviewBrief || null, loopInterviewHandoff: session.loopInterviewHandoff || null, scheduledTaskDraft: session.scheduledTaskDraft || null });
|
|
869
|
+
_send({ type: "session_switched", id: localId, sessionOriginId: session.sessionOriginId || null, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: _permissionState.requestedPermissionMode, effectivePermissionMode: _permissionState.effectivePermissionMode, permissionCapabilities: _permissionState.permissionCapabilities, mcpPermissionModeOverrides: _permissionState.mcpPermissionModeOverrides, autonomousRun: projectAutonomousRun(session.autonomousRun), loopInterviewBrief: session.loopInterviewBrief || null, loopInterviewHandoff: session.loopInterviewHandoff || null, scheduledTaskDraft: session.scheduledTaskDraft || null });
|
|
850
870
|
// Send vendor-specific slash commands
|
|
851
871
|
var _vendorCmds = (session.slashCommandsByVendor && session.slashCommandsByVendor[_sessionVendor])
|
|
852
872
|
|| session.slashCommands
|
|
@@ -915,10 +935,9 @@ function createSessionManager(opts) {
|
|
|
915
935
|
session.worker = null;
|
|
916
936
|
}
|
|
917
937
|
|
|
918
|
-
if (session.cliSessionId)
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
}
|
|
938
|
+
if (session.cliSessionId) rememberDeletedCliSession(session.cliSessionId);
|
|
939
|
+
var recordId = existingSessionRecordId(session);
|
|
940
|
+
if (recordId) try { fs.unlinkSync(sessionFilePath(recordId)); } catch(e) {}
|
|
922
941
|
|
|
923
942
|
sessions.delete(localId);
|
|
924
943
|
try { onSessionDeleted(localId, session); } catch (e) {
|
|
@@ -962,10 +981,9 @@ function createSessionManager(opts) {
|
|
|
962
981
|
try { session.worker.kill(); } catch(e) {}
|
|
963
982
|
session.worker = null;
|
|
964
983
|
}
|
|
965
|
-
if (session.cliSessionId)
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
}
|
|
984
|
+
if (session.cliSessionId) rememberDeletedCliSession(session.cliSessionId);
|
|
985
|
+
var recordId = existingSessionRecordId(session);
|
|
986
|
+
if (recordId) try { fs.unlinkSync(sessionFilePath(recordId)); } catch(e) {}
|
|
969
987
|
sessions.delete(localId);
|
|
970
988
|
try { onSessionDeleted(localId, session); } catch (e) {
|
|
971
989
|
console.error("[session] Session deletion hook failed:", e.message || e);
|
|
@@ -1031,7 +1049,7 @@ function createSessionManager(opts) {
|
|
|
1031
1049
|
var outgoingObj = clientObj || obj;
|
|
1032
1050
|
if (!outgoingObj._ts) outgoingObj._ts = obj._ts;
|
|
1033
1051
|
session.history.push(obj);
|
|
1034
|
-
appendToSessionFile(session, obj);
|
|
1052
|
+
if (appendToSessionFile(session, obj) === false) return false;
|
|
1035
1053
|
// Per-session out-of-band subscribers mirror mate session events into
|
|
1036
1054
|
// a parallel UI without joining the project's
|
|
1037
1055
|
// ws clients set). Subscribers receive the same obj that goes to ws
|
|
@@ -1087,6 +1105,35 @@ function createSessionManager(opts) {
|
|
|
1087
1105
|
}
|
|
1088
1106
|
// Notify server for cross-project unread tracking
|
|
1089
1107
|
if (obj.type === "done") onSessionDone(session);
|
|
1108
|
+
return true;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function sendAndRecordDurably(session, obj, clientObj) {
|
|
1112
|
+
var recordId = existingSessionRecordId(session);
|
|
1113
|
+
if (!recordId || !fs.existsSync(sessionFilePath(recordId))) return false;
|
|
1114
|
+
var saved;
|
|
1115
|
+
try { saved = doSendAndRecord(session, obj, clientObj); }
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
var exact = JSON.stringify(obj);
|
|
1118
|
+
if (hasDurableSessionRecord(session, function (record) { return JSON.stringify(record) === exact; })) return true;
|
|
1119
|
+
if (session.history[session.history.length - 1] === obj) session.history.pop();
|
|
1120
|
+
throw error;
|
|
1121
|
+
}
|
|
1122
|
+
if (saved === false && session.history[session.history.length - 1] === obj) session.history.pop();
|
|
1123
|
+
return saved === true;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function hasDurableSessionRecord(session, predicate) {
|
|
1127
|
+
var recordId = existingSessionRecordId(session);
|
|
1128
|
+
if (!recordId || typeof predicate !== "function") return false;
|
|
1129
|
+
try {
|
|
1130
|
+
var lines = fs.readFileSync(sessionFilePath(recordId), "utf8").split("\n");
|
|
1131
|
+
for (var i = lines.length - 1; i >= 0; i--) {
|
|
1132
|
+
if (!lines[i]) continue;
|
|
1133
|
+
try { if (predicate(JSON.parse(lines[i]))) return true; } catch (error) {}
|
|
1134
|
+
}
|
|
1135
|
+
} catch (error) {}
|
|
1136
|
+
return false;
|
|
1090
1137
|
}
|
|
1091
1138
|
|
|
1092
1139
|
function resumeSession(cliSessionId, opts, targetWs) {
|
|
@@ -1384,6 +1431,8 @@ function createSessionManager(opts) {
|
|
|
1384
1431
|
saveSessionFile: saveSessionFile,
|
|
1385
1432
|
appendToSessionFile: appendToSessionFile,
|
|
1386
1433
|
sendAndRecord: doSendAndRecord,
|
|
1434
|
+
sendAndRecordDurably: sendAndRecordDurably,
|
|
1435
|
+
hasDurableSessionRecord: hasDurableSessionRecord,
|
|
1387
1436
|
subscribeSession: function (localId, cb) {
|
|
1388
1437
|
var session = sessions.get(localId);
|
|
1389
1438
|
if (!session) return null;
|
package/lib/ws-schema.js
CHANGED
|
@@ -41,6 +41,8 @@ var schema = {
|
|
|
41
41
|
"split_group_rename": { direction: "c2s", handler: "lib/session-split-groups.js", description: "Rename a split group" },
|
|
42
42
|
"split_group_dissolve": { direction: "c2s", handler: "lib/session-split-groups.js", description: "Separate a split group without deleting its sessions" },
|
|
43
43
|
"split_group_set_pair": { direction: "c2s", handler: "lib/session-split-groups.js", description: "Assign Driver/Worker roles on a split group (driverId null clears them)" },
|
|
44
|
+
"split_worker_close": { direction: "c2s", handler: "lib/project-pair-worker-close.js", description: "Close one exact authenticated Worker generation while preserving its peer and session history" },
|
|
45
|
+
"split_worker_close_result": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Correlated result of an exact targeted Worker close" },
|
|
44
46
|
"pair_session_options": { direction: "c2s", handler: "lib/project-session-pair.js", description: "Request vendors/models for the pair-session dialog (response reuses the same type)" },
|
|
45
47
|
"pair_session_create": { direction: "c2s", handler: "lib/project-session-pair.js", description: "Create a Driver/Split Worker session pair as a split group" },
|
|
46
48
|
"worker_proposal_response": { direction: "c2s", handler: "lib/project-worker-proposal.js", description: "Accept or decline an exact Driver Split Worker configuration with the selected runtime" },
|
package/package.json
CHANGED