clay-server 4.2.0 → 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.
Files changed (84) hide show
  1. package/lib/driver-continuation-access.js +54 -0
  2. package/lib/driver-continuation-lease.js +52 -0
  3. package/lib/driver-continuation-pair-transfer.js +86 -0
  4. package/lib/driver-continuation-pair.js +86 -0
  5. package/lib/driver-continuation-record.js +40 -0
  6. package/lib/driver-continuation-startup.js +68 -0
  7. package/lib/driver-continuation-transaction.js +61 -0
  8. package/lib/driver-continuation-trigger.js +110 -0
  9. package/lib/multi-worker-feature.js +11 -0
  10. package/lib/project-connection.js +3 -1
  11. package/lib/project-driver-continuation.js +499 -0
  12. package/lib/project-message-delivery.js +20 -0
  13. package/lib/project-pair-autonomous-stop.js +14 -8
  14. package/lib/project-pair-close-transaction.js +59 -0
  15. package/lib/project-pair-global-stop.js +18 -0
  16. package/lib/project-pair-lifecycle-status.js +34 -0
  17. package/lib/project-pair-lifecycle.js +117 -135
  18. package/lib/project-pair-message.js +1 -1
  19. package/lib/project-pair-owned-stop.js +7 -4
  20. package/lib/project-pair-replacement-state.js +5 -1
  21. package/lib/project-pair-result-capture.js +116 -0
  22. package/lib/project-pair-result-delivery.js +140 -0
  23. package/lib/project-pair-result-outbox.js +400 -0
  24. package/lib/project-pair-result-pipeline.js +16 -0
  25. package/lib/project-pair-result-recovery.js +229 -0
  26. package/lib/project-pair-structural-close.js +20 -0
  27. package/lib/project-pair-task-control.js +45 -18
  28. package/lib/project-pair-worker-close.js +68 -0
  29. package/lib/project-session-handoff.js +48 -55
  30. package/lib/project-session-pair-prompt.js +26 -0
  31. package/lib/project-session-pair.js +87 -106
  32. package/lib/project-sessions.js +4 -0
  33. package/lib/project-user-message.js +21 -0
  34. package/lib/project-worker-creation-proposal.js +176 -0
  35. package/lib/project-worker-permission.js +7 -8
  36. package/lib/project-worker-proposal-runner.js +45 -0
  37. package/lib/project-worker-proposal.js +79 -115
  38. package/lib/project.js +87 -3
  39. package/lib/public/app.js +1 -0
  40. package/lib/public/css/driver-continuation.css +56 -0
  41. package/lib/public/css/messages.css +12 -0
  42. package/lib/public/css/mobile-nav.css +29 -14
  43. package/lib/public/css/overlays.css +54 -25
  44. package/lib/public/css/pane.css +66 -12
  45. package/lib/public/css/sidebar.css +62 -7
  46. package/lib/public/index.html +3 -3
  47. package/lib/public/modules/app-connection.js +165 -74
  48. package/lib/public/modules/app-messages.js +31 -3
  49. package/lib/public/modules/driver-continuation-state.js +76 -0
  50. package/lib/public/modules/driver-continuation.js +260 -0
  51. package/lib/public/modules/message-delivery-ui.js +63 -0
  52. package/lib/public/modules/message-delivery.js +163 -6
  53. package/lib/public/modules/pair-result-status.js +87 -0
  54. package/lib/public/modules/permission-control.js +10 -5
  55. package/lib/public/modules/project-activation.js +10 -7
  56. package/lib/public/modules/session-creation-cta.js +46 -0
  57. package/lib/public/modules/sidebar-mobile.js +42 -30
  58. package/lib/public/modules/sidebar-sessions.js +77 -52
  59. package/lib/public/modules/split-group-helpers.js +74 -2
  60. package/lib/public/modules/split-pair-ui.js +28 -6
  61. package/lib/public/modules/split-pane-reconciler.js +28 -0
  62. package/lib/public/modules/split-pane-renderer.js +107 -0
  63. package/lib/public/modules/split-view.js +58 -188
  64. package/lib/public/modules/websocket-lifecycle.js +178 -0
  65. package/lib/public/modules/websocket-watchdog.js +126 -0
  66. package/lib/public/modules/worker-pane-lock.js +13 -3
  67. package/lib/public/style.css +1 -0
  68. package/lib/sdk-bridge.js +77 -0
  69. package/lib/server.js +1 -0
  70. package/lib/session-handoff-discovery.js +443 -0
  71. package/lib/session-handoff-mcp-server.js +32 -4
  72. package/lib/session-pair-factory.js +66 -0
  73. package/lib/session-pair-mcp-server.js +10 -5
  74. package/lib/session-pair-prompts.js +46 -25
  75. package/lib/session-pair-target.js +42 -0
  76. package/lib/session-pair-turn-control.js +30 -9
  77. package/lib/session-split-group-anchors.js +126 -0
  78. package/lib/session-split-group-persistence.js +136 -0
  79. package/lib/session-split-group-roles.js +164 -0
  80. package/lib/session-split-group-v2-store.js +146 -0
  81. package/lib/session-split-groups.js +293 -133
  82. package/lib/sessions.js +89 -22
  83. package/lib/ws-schema.js +8 -0
  84. package/package.json +1 -1
@@ -1,151 +1,71 @@
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;
8
10
  }
9
-
10
11
  function autoGroupName(leftTitle, rightTitle) {
11
12
  return truncateTitle(leftTitle) + " | " + truncateTitle(rightTitle);
12
13
  }
13
-
14
14
  function createSplitGroupStore(opts) {
15
15
  var sessions = opts.sessions;
16
- var groupsFile = path.join(opts.sessionsDir, "split-groups.json");
17
16
  var usersModule = opts.usersModule;
18
17
  var broadcast = opts.broadcast || function () {};
19
18
  var onPairChanged = opts.onPairChanged || function () {};
20
19
  var groups = [];
21
-
22
- // localIds are reassigned on every daemon restart (loadSessions renumbers
23
- // by createdAt), so persisted member localIds go stale as soon as the
24
- // session set changes. cliSessionIds are the durable anchor: keep them on
25
- // every record and remap members from them at load.
26
- function currentCliIds(group) {
27
- var left = sessions.get(group.members[0]);
28
- var right = sessions.get(group.members[1]);
29
- var prev = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
30
- return [
31
- (left && left.cliSessionId) || prev[0] || null,
32
- (right && right.cliSessionId) || prev[1] || null,
33
- ];
34
- }
35
-
36
- function refreshPairAnchors(group) {
37
- if (!group.pair) return;
38
- var driver = sessions.get(group.pair.driverId);
39
- var worker = sessions.get(group.pair.workerId);
40
- group.pairCliIds = [
41
- (driver && driver.cliSessionId) || (group.pairCliIds && group.pairCliIds[0]) || null,
42
- (worker && worker.cliSessionId) || (group.pairCliIds && group.pairCliIds[1]) || null,
43
- ];
44
- }
45
-
46
- function sessionByCliId(cliId) {
47
- if (!cliId) return null;
48
- var found = null;
49
- sessions.forEach(function (s) {
50
- if (s.cliSessionId === cliId) found = s;
51
- });
52
- return found;
53
- }
54
-
55
- function save() {
56
- for (var gi = 0; gi < groups.length; gi++) {
57
- groups[gi].memberCliIds = currentCliIds(groups[gi]);
58
- refreshPairAnchors(groups[gi]);
59
- }
60
- var tmp = groupsFile + ".tmp." + process.pid;
61
- fs.mkdirSync(opts.sessionsDir, { recursive: true });
62
- fs.writeFileSync(tmp, JSON.stringify(groups, null, 2) + "\n");
63
- if (process.platform !== "win32") {
64
- try { fs.chmodSync(tmp, 0o600); } catch (e) {}
65
- }
66
- fs.renameSync(tmp, groupsFile);
67
- }
68
-
69
- function validRecord(group) {
70
- return group && typeof group.id === "string" && Array.isArray(group.members) &&
71
- group.members.length === 2 && group.members[0] !== group.members[1] &&
72
- Number.isInteger(group.members[0]) && Number.isInteger(group.members[1]) &&
73
- sessions.has(group.members[0]) && sessions.has(group.members[1]);
74
- }
75
-
76
- function load() {
77
- var loaded = [];
78
- var shouldRewrite = false;
79
- try {
80
- loaded = JSON.parse(fs.readFileSync(groupsFile, "utf8"));
81
- if (!Array.isArray(loaded)) loaded = [];
82
- } catch (e) {
83
- if (e.code !== "ENOENT") shouldRewrite = true;
84
- }
85
- var claimed = {};
86
- groups = loaded.filter(function (group) {
87
- // Remap members from the durable cliSessionIds; the stored localIds
88
- // are only trusted for legacy records that predate memberCliIds.
89
- if (group && Array.isArray(group.memberCliIds)) {
90
- var left = sessionByCliId(group.memberCliIds[0]);
91
- var right = sessionByCliId(group.memberCliIds[1]);
92
- if (!left || !right || left === right) return false;
93
- if (group.members[0] !== left.localId || group.members[1] !== right.localId) {
94
- group.members = [left.localId, right.localId];
95
- shouldRewrite = true;
96
- }
97
- }
98
- if (group && group.pair && Array.isArray(group.pairCliIds)) {
99
- var driver = sessionByCliId(group.pairCliIds[0]);
100
- var worker = sessionByCliId(group.pairCliIds[1]);
101
- if (driver && worker) {
102
- group.pair.driverId = driver.localId;
103
- group.pair.workerId = worker.localId;
104
- }
105
- }
106
- if (group && group.pair) {
107
- var pairValid = Array.isArray(group.members) && group.pair.driverId !== group.pair.workerId &&
108
- group.members.indexOf(group.pair.driverId) !== -1 &&
109
- group.members.indexOf(group.pair.workerId) !== -1;
110
- if (!pairValid) return false;
111
- }
112
- if (!validRecord(group) || claimed[group.members[0]] || claimed[group.members[1]]) return false;
113
- claimed[group.members[0]] = true;
114
- claimed[group.members[1]] = true;
115
- return true;
116
- });
117
- if (groups.length !== loaded.length) shouldRewrite = true;
118
- if (shouldRewrite) save();
119
- }
120
-
20
+ var stagedTransfers = [];
21
+ var versionedEnabled = multiWorkerFeature.isEnabled(opts.multiWorkerFeature);
22
+ var persistence = groupPersistence.attachPersistence(Object.assign({}, opts, { versionedEnabled: versionedEnabled }));
23
+ function save() { return persistence.save(groups); }
121
24
  function isMultiUser() {
122
25
  return !!(usersModule && usersModule.isMultiUser && usersModule.isMultiUser());
123
26
  }
124
-
125
27
  function listFor(ws) {
126
28
  if (!isMultiUser()) return groups.slice();
127
29
  if (!ws || !ws._clayUser) return [];
128
30
  return groups.filter(function (group) { return group.ownerId === ws._clayUser.id; });
129
31
  }
130
-
131
32
  function groupForMember(localId) {
33
+ for (var si = 0; si < stagedTransfers.length; si++) {
34
+ var transfer = stagedTransfers[si];
35
+ if (!exactTransfer(transfer)) continue;
36
+ if (transfer.source.localId === localId) return null;
37
+ if (transfer.target.localId === localId || transfer.worker.localId === localId) return transfer.runtimeGroup;
38
+ }
132
39
  for (var i = 0; i < groups.length; i++) {
133
40
  if (groups[i].members.indexOf(localId) !== -1) return groups[i];
134
41
  }
135
42
  return null;
136
43
  }
137
-
44
+ function membershipGroup(localId) {
45
+ for (var si = 0; si < stagedTransfers.length; si++) {
46
+ var transfer = stagedTransfers[si];
47
+ if (transfer.source.localId === localId || transfer.target.localId === localId ||
48
+ transfer.worker.localId === localId) return transfer.group;
49
+ }
50
+ for (var i = 0; i < groups.length; i++) if (groups[i].members.indexOf(localId) !== -1) return groups[i];
51
+ return null;
52
+ }
53
+ function groupCountForMember(localId) {
54
+ var count = 0;
55
+ for (var i = 0; i < groups.length; i++) {
56
+ if (groups[i].members.indexOf(localId) !== -1) count++;
57
+ }
58
+ return count;
59
+ }
138
60
  function canAccess(ws, session) {
139
61
  if (!isMultiUser()) return true;
140
62
  return !!(ws && ws._clayUser && usersModule.canAccessSession(
141
63
  ws._clayUser.id, session, { visibility: "public" }
142
64
  ));
143
65
  }
144
-
145
66
  function canOwn(ws, group) {
146
67
  return !isMultiUser() || !!(ws && ws._clayUser && group.ownerId === ws._clayUser.id);
147
68
  }
148
-
149
69
  function create(ws, msg) {
150
70
  var members = msg && msg.members;
151
71
  if (!Array.isArray(members) || members.length !== 2) return { ok: false, error: "A split group requires exactly two sessions" };
@@ -156,7 +76,9 @@ function createSplitGroupStore(opts) {
156
76
  var right = sessions.get(members[1]);
157
77
  if (!left || !right) return { ok: false, error: "Session not found" };
158
78
  if (!canAccess(ws, left) || !canAccess(ws, right)) return { ok: false, error: "Session access denied" };
159
- if (groupForMember(members[0]) || groupForMember(members[1])) return { ok: false, error: "A session can belong to only one split group" };
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 };
160
82
  var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
161
83
  var group = {
162
84
  id: "sg_" + Date.now().toString(36) + "_" + crypto.randomBytes(3).toString("hex"),
@@ -178,7 +100,6 @@ function createSplitGroupStore(opts) {
178
100
  if (group.pair) onPairChanged(group);
179
101
  return { ok: true, group: group };
180
102
  }
181
-
182
103
  function createOwned(ownerId, msg) {
183
104
  var members = msg && msg.members;
184
105
  if (!Array.isArray(members) || members.length !== 2) return { ok: false, error: "A split group requires exactly two sessions" };
@@ -186,9 +107,10 @@ function createSplitGroupStore(opts) {
186
107
  var right = sessions.get(members[1]);
187
108
  if (!left || !right || left === right) return { ok: false, error: "Session not found" };
188
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" };
189
- if (groupForMember(left.localId) || groupForMember(right.localId)) return { ok: false, error: "A session can belong to only one split group" };
110
+ if (membershipGroup(left.localId) || membershipGroup(right.localId)) return { ok: false, error: "A session can belong to only one split group" };
190
111
  var pair = msg && msg.pair;
191
- if (!pair || pair.driverId !== left.localId || pair.workerId !== right.localId) return { ok: false, error: "Pair roles must reference both split group members" };
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" };
192
114
  var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
193
115
  var group = {
194
116
  id: "sg_" + Date.now().toString(36) + "_" + crypto.randomBytes(3).toString("hex"),
@@ -202,7 +124,6 @@ function createSplitGroupStore(opts) {
202
124
  onPairChanged(group);
203
125
  return { ok: true, group: group };
204
126
  }
205
-
206
127
  function dissolveOwned(ownerId, id) {
207
128
  var index = groups.findIndex(function (item) { return item.id === id && (item.ownerId || null) === (ownerId || null); });
208
129
  if (index === -1) return false;
@@ -212,7 +133,158 @@ function createSplitGroupStore(opts) {
212
133
  onPairChanged(removed);
213
134
  return true;
214
135
  }
215
-
136
+ function beginOwnedDriverTransfer(ownerId, msg) {
137
+ var group = groups.find(function (item) { return item.id === (msg && msg.id); });
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
+ }
143
+ if (!group.pair || group.pair.driverId !== msg.sourceDriverId || group.pair.workerId !== msg.workerId) {
144
+ return { ok: false, error: "The split pair changed before Driver transfer" };
145
+ }
146
+ var source = sessions.get(msg.sourceDriverId);
147
+ var target = sessions.get(msg.targetDriverId);
148
+ var worker = sessions.get(msg.workerId);
149
+ if (!source || !target || !worker || source === target || target === worker) return { ok: false, error: "Session not found" };
150
+ if ((source.ownerId || null) !== (ownerId || null) || (target.ownerId || null) !== (ownerId || null) ||
151
+ (worker.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Split pair owner changed" };
152
+ if (membershipGroup(target.localId)) return { ok: false, error: "The successor already belongs to a split group" };
153
+ var sourceIndex = group.members.indexOf(source.localId);
154
+ if (sourceIndex === -1 || group.members.indexOf(worker.localId) === -1) return { ok: false, error: "Split group members changed" };
155
+ var before = {
156
+ members: group.members.slice(),
157
+ memberCliIds: Array.isArray(group.memberCliIds) ? group.memberCliIds.slice() : null,
158
+ memberOriginIds: Array.isArray(group.memberOriginIds) ? group.memberOriginIds.slice() : null,
159
+ pair: Object.assign({}, group.pair),
160
+ pairCliIds: Array.isArray(group.pairCliIds) ? group.pairCliIds.slice() : null,
161
+ pairOriginIds: Array.isArray(group.pairOriginIds) ? group.pairOriginIds.slice() : null,
162
+ };
163
+ var runtimeMembers = group.members.slice();
164
+ runtimeMembers[sourceIndex] = target.localId;
165
+ var runtimeGroup = Object.assign({}, group, { members: runtimeMembers,
166
+ pair: { driverId: target.localId, workerId: worker.localId } });
167
+ var transaction = { group: group, before: before, source: source, target: target, worker: worker,
168
+ runtimeGroup: runtimeGroup,
169
+ ownerId: ownerId || null, sourceOriginId: source.sessionOriginId || null,
170
+ targetOriginId: target.sessionOriginId || null, workerOriginId: worker.sessionOriginId || null,
171
+ workerCliSessionId: worker.cliSessionId || null, staged: true, committed: false };
172
+ stagedTransfers.push(transaction);
173
+ return { ok: true, group: runtimeGroup, transaction: transaction };
174
+ }
175
+ function removeStaged(transaction) {
176
+ var index = stagedTransfers.indexOf(transaction);
177
+ if (index !== -1) stagedTransfers.splice(index, 1);
178
+ }
179
+ function restoreTransfer(transaction) {
180
+ var group = transaction.group;
181
+ group.members = transaction.before.members.slice();
182
+ group.pair = Object.assign({}, transaction.before.pair);
183
+ if (transaction.before.memberCliIds) group.memberCliIds = transaction.before.memberCliIds.slice();
184
+ else delete group.memberCliIds;
185
+ if (transaction.before.memberOriginIds) group.memberOriginIds = transaction.before.memberOriginIds.slice();
186
+ else delete group.memberOriginIds;
187
+ if (transaction.before.pairCliIds) group.pairCliIds = transaction.before.pairCliIds.slice();
188
+ else delete group.pairCliIds;
189
+ if (transaction.before.pairOriginIds) group.pairOriginIds = transaction.before.pairOriginIds.slice();
190
+ else delete group.pairOriginIds;
191
+ }
192
+ function stagedTransfer(transaction) {
193
+ return transaction && transaction.staged && !transaction.committed &&
194
+ stagedTransfers.indexOf(transaction) !== -1 && groups.indexOf(transaction.group) !== -1 &&
195
+ transaction.runtimeGroup.pair && transaction.runtimeGroup.pair.driverId === transaction.target.localId &&
196
+ transaction.runtimeGroup.pair.workerId === transaction.worker.localId &&
197
+ transaction.runtimeGroup.members.length === 2 && transaction.runtimeGroup.members.indexOf(transaction.target.localId) !== -1 &&
198
+ transaction.runtimeGroup.members.indexOf(transaction.worker.localId) !== -1;
199
+ }
200
+ function exactTransfer(transaction) {
201
+ return stagedTransfer(transaction) &&
202
+ sessions.get(transaction.source.localId) === transaction.source &&
203
+ sessions.get(transaction.target.localId) === transaction.target &&
204
+ sessions.get(transaction.worker.localId) === transaction.worker &&
205
+ (transaction.group.ownerId || null) === transaction.ownerId &&
206
+ (transaction.source.ownerId || null) === transaction.ownerId &&
207
+ (transaction.target.ownerId || null) === transaction.ownerId &&
208
+ (transaction.worker.ownerId || null) === transaction.ownerId &&
209
+ (transaction.source.sessionOriginId || null) === transaction.sourceOriginId &&
210
+ (transaction.target.sessionOriginId || null) === transaction.targetOriginId &&
211
+ (transaction.worker.sessionOriginId || null) === transaction.workerOriginId &&
212
+ (transaction.worker.cliSessionId || null) === transaction.workerCliSessionId &&
213
+ transaction.group.pair && transaction.group.pair.driverId === transaction.source.localId &&
214
+ transaction.group.pair.workerId === transaction.worker.localId &&
215
+ transaction.group.members.length === 2 && transaction.group.members.indexOf(transaction.source.localId) !== -1 &&
216
+ transaction.group.members.indexOf(transaction.worker.localId) !== -1 &&
217
+ groupCountForMember(transaction.target.localId) === 0 && groupCountForMember(transaction.worker.localId) === 1 &&
218
+ groupCountForMember(transaction.source.localId) === 1;
219
+ }
220
+ function commitOwnedDriverTransfer(transaction) {
221
+ if (!exactTransfer(transaction)) {
222
+ removeStaged(transaction);
223
+ transaction.staged = false;
224
+ return { ok: false, error: "The staged Driver transfer is no longer exact" };
225
+ }
226
+ if (!transaction.target.cliSessionId ||
227
+ (!transaction.worker.cliSessionId && !transaction.worker.sessionOriginId)) {
228
+ return { ok: false, error: "The transferred pair does not have durable session anchors" };
229
+ }
230
+ transaction.group.members = transaction.runtimeGroup.members.slice();
231
+ transaction.group.pair = Object.assign({}, transaction.runtimeGroup.pair);
232
+ try { save(); }
233
+ catch (error) {
234
+ restoreTransfer(transaction);
235
+ removeStaged(transaction);
236
+ transaction.staged = false;
237
+ return { ok: false, error: error.message || String(error) };
238
+ }
239
+ removeStaged(transaction);
240
+ transaction.committed = true;
241
+ broadcast();
242
+ onPairChanged(transaction.group);
243
+ return { ok: true, group: transaction.group };
244
+ }
245
+ function rollbackOwnedDriverTransfer(transaction) {
246
+ if (stagedTransfer(transaction)) {
247
+ removeStaged(transaction);
248
+ transaction.staged = false;
249
+ return { ok: true, group: transaction.group };
250
+ }
251
+ if (!transaction || !transaction.committed || groups.indexOf(transaction.group) === -1 ||
252
+ transaction.group.pair.driverId !== transaction.target.localId ||
253
+ transaction.group.pair.workerId !== transaction.worker.localId) {
254
+ return { ok: false, error: "The staged Driver transfer is no longer exact" };
255
+ }
256
+ var wasCommitted = transaction.committed;
257
+ var committedState = {
258
+ members: transaction.group.members.slice(),
259
+ memberCliIds: Array.isArray(transaction.group.memberCliIds) ? transaction.group.memberCliIds.slice() : null,
260
+ memberOriginIds: Array.isArray(transaction.group.memberOriginIds) ? transaction.group.memberOriginIds.slice() : null,
261
+ pair: Object.assign({}, transaction.group.pair),
262
+ pairCliIds: Array.isArray(transaction.group.pairCliIds) ? transaction.group.pairCliIds.slice() : null,
263
+ pairOriginIds: Array.isArray(transaction.group.pairOriginIds) ? transaction.group.pairOriginIds.slice() : null,
264
+ };
265
+ restoreTransfer(transaction);
266
+ if (wasCommitted) {
267
+ try { save(); }
268
+ catch (error) {
269
+ transaction.group.members = committedState.members;
270
+ transaction.group.pair = committedState.pair;
271
+ if (committedState.memberCliIds) transaction.group.memberCliIds = committedState.memberCliIds;
272
+ else delete transaction.group.memberCliIds;
273
+ if (committedState.memberOriginIds) transaction.group.memberOriginIds = committedState.memberOriginIds;
274
+ else delete transaction.group.memberOriginIds;
275
+ if (committedState.pairCliIds) transaction.group.pairCliIds = committedState.pairCliIds;
276
+ else delete transaction.group.pairCliIds;
277
+ if (committedState.pairOriginIds) transaction.group.pairOriginIds = committedState.pairOriginIds;
278
+ else delete transaction.group.pairOriginIds;
279
+ return { ok: false, error: error.message || String(error) };
280
+ }
281
+ broadcast();
282
+ onPairChanged(transaction.group);
283
+ }
284
+ transaction.staged = false;
285
+ transaction.committed = false;
286
+ return { ok: true, group: transaction.group };
287
+ }
216
288
  function rename(ws, msg) {
217
289
  var group = groups.find(function (item) { return item.id === (msg && msg.id); });
218
290
  if (!group) return { ok: false, error: "Split group not found" };
@@ -237,6 +309,7 @@ function createSplitGroupStore(opts) {
237
309
  if (msg.driverId == null) {
238
310
  delete group.pair;
239
311
  delete group.pairCliIds;
312
+ delete group.pairOriginIds;
240
313
  save();
241
314
  broadcast();
242
315
  onPairChanged(group);
@@ -246,6 +319,8 @@ function createSplitGroupStore(opts) {
246
319
  return { ok: false, error: "Driver must be a member of the split group" };
247
320
  }
248
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 };
249
324
  group.pair = { driverId: msg.driverId, workerId: workerId };
250
325
  save();
251
326
  broadcast();
@@ -253,31 +328,73 @@ function createSplitGroupStore(opts) {
253
328
  return { ok: true, group: group };
254
329
  }
255
330
 
256
- function dissolve(ws, msg) {
331
+ function dissolve(ws, msg, hooks) {
257
332
  var index = groups.findIndex(function (item) { return item.id === (msg && msg.id); });
258
333
  if (index === -1) return { ok: false, error: "Split group not found" };
259
334
  if (!canOwn(ws, groups[index])) return { ok: false, error: "Only the group owner can separate it" };
260
335
  var removed = groups.splice(index, 1)[0];
261
- 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
+ }
262
353
  broadcast();
263
354
  return { ok: true, group: removed };
264
355
  }
265
356
 
266
357
  function dissolveBySession(localId) {
267
- var before = groups.length;
268
- groups = groups.filter(function (group) { return group.members.indexOf(localId) === -1; });
269
- if (groups.length === before) return false;
270
- save();
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
+ }
271
380
  broadcast();
381
+ onPairChanged(group);
272
382
  return true;
273
383
  }
274
384
 
275
385
  function refreshAnchors(localId) {
276
386
  var group = groupForMember(localId);
277
387
  if (!group) return false;
278
- var fresh = currentCliIds(group);
279
- var prev = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
280
- if (fresh[0] === prev[0] && fresh[1] === prev[1]) return false;
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
+ }
394
+ var prevCli = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
395
+ var prevOrigin = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
396
+ if (fresh.cli[0] === prevCli[0] && fresh.cli[1] === prevCli[1] &&
397
+ fresh.origin[0] === prevOrigin[0] && fresh.origin[1] === prevOrigin[1]) return false;
281
398
  save();
282
399
  return true;
283
400
  }
@@ -298,8 +415,54 @@ function createSplitGroupStore(opts) {
298
415
  return true;
299
416
  }
300
417
 
301
- load();
302
- return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned, rename: rename, setPair: setPair, dissolve: dissolve, dissolveBySession: dissolveBySession,
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();
460
+ return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned,
461
+ beginOwnedDriverTransfer: beginOwnedDriverTransfer, commitOwnedDriverTransfer: commitOwnedDriverTransfer,
462
+ rollbackOwnedDriverTransfer: rollbackOwnedDriverTransfer,
463
+ rename: rename, setPair: setPair, dissolve: dissolve, dissolveBySession: dissolveBySession,
464
+ addWorker: versionedMutations.addWorker, replaceWorker: versionedMutations.replaceWorker,
465
+ removeWorker: versionedMutations.removeWorker,
303
466
  refreshAutoName: refreshAutoName, refreshAnchors: refreshAnchors,
304
467
  listFor: listFor, groupForMember: groupForMember, get groups() { return groups; } };
305
468
  }
@@ -311,13 +474,11 @@ function attachSplitGroups(ctx) {
311
474
  usersModule: ctx.usersModule,
312
475
  broadcast: broadcast,
313
476
  onPairChanged: ctx.onPairChanged,
477
+ multiWorkerFeature: ctx.multiWorkerFeature,
314
478
  });
315
479
 
316
480
  function sendState(ws) {
317
481
  if (!ws) return;
318
- // Pane clients need the same owner-scoped pair roles as the parent shell.
319
- // The Worker composer lock resolves its role from this projection; hiding
320
- // the projection from panes leaves a configured Worker looking editable.
321
482
  ctx.sendTo(ws, { type: "split_groups", groups: store.listFor(ws) });
322
483
  }
323
484
 
@@ -345,5 +506,4 @@ function attachSplitGroups(ctx) {
345
506
  return { handleMessage: handleMessage, sendConnectionState: sendState, store: store };
346
507
  }
347
508
 
348
- module.exports = { attachSplitGroups: attachSplitGroups, createSplitGroupStore: createSplitGroupStore,
349
- autoGroupName: autoGroupName };
509
+ module.exports = { attachSplitGroups: attachSplitGroups, createSplitGroupStore: createSplitGroupStore, autoGroupName: autoGroupName };