clay-server 4.3.0-beta.4 → 4.3.0-beta.6

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 (54) hide show
  1. package/lib/driver-continuation-pair.js +5 -1
  2. package/lib/multi-worker-feature.js +11 -0
  3. package/lib/project-connection.js +2 -0
  4. package/lib/project-pair-autonomous-stop.js +14 -8
  5. package/lib/project-pair-close-transaction.js +59 -0
  6. package/lib/project-pair-global-stop.js +18 -0
  7. package/lib/project-pair-lifecycle-status.js +34 -0
  8. package/lib/project-pair-lifecycle.js +58 -76
  9. package/lib/project-pair-message.js +1 -1
  10. package/lib/project-pair-owned-stop.js +7 -4
  11. package/lib/project-pair-result-capture.js +116 -0
  12. package/lib/project-pair-result-delivery.js +140 -0
  13. package/lib/project-pair-result-outbox.js +400 -0
  14. package/lib/project-pair-result-pipeline.js +16 -0
  15. package/lib/project-pair-result-recovery.js +229 -0
  16. package/lib/project-pair-structural-close.js +20 -0
  17. package/lib/project-pair-task-control.js +45 -18
  18. package/lib/project-pair-worker-close.js +68 -0
  19. package/lib/project-session-pair-prompt.js +26 -0
  20. package/lib/project-session-pair.js +87 -106
  21. package/lib/project-worker-creation-proposal.js +176 -0
  22. package/lib/project-worker-permission.js +7 -8
  23. package/lib/project-worker-proposal-runner.js +45 -0
  24. package/lib/project-worker-proposal.js +79 -115
  25. package/lib/project.js +38 -1
  26. package/lib/public/app.js +1 -0
  27. package/lib/public/css/messages.css +12 -0
  28. package/lib/public/css/pane.css +48 -2
  29. package/lib/public/modules/app-connection.js +49 -76
  30. package/lib/public/modules/app-messages.js +11 -0
  31. package/lib/public/modules/pair-result-status.js +87 -0
  32. package/lib/public/modules/project-activation.js +10 -7
  33. package/lib/public/modules/sidebar-sessions.js +31 -11
  34. package/lib/public/modules/split-group-helpers.js +74 -2
  35. package/lib/public/modules/split-pair-ui.js +28 -6
  36. package/lib/public/modules/split-pane-reconciler.js +28 -0
  37. package/lib/public/modules/split-pane-renderer.js +107 -0
  38. package/lib/public/modules/split-view.js +58 -188
  39. package/lib/public/modules/websocket-watchdog.js +126 -0
  40. package/lib/public/modules/worker-pane-lock.js +13 -3
  41. package/lib/server.js +1 -0
  42. package/lib/session-pair-factory.js +66 -0
  43. package/lib/session-pair-mcp-server.js +10 -5
  44. package/lib/session-pair-prompts.js +41 -25
  45. package/lib/session-pair-target.js +42 -0
  46. package/lib/session-pair-turn-control.js +11 -6
  47. package/lib/session-split-group-anchors.js +89 -7
  48. package/lib/session-split-group-persistence.js +136 -0
  49. package/lib/session-split-group-roles.js +164 -0
  50. package/lib/session-split-group-v2-store.js +146 -0
  51. package/lib/session-split-groups.js +109 -99
  52. package/lib/sessions.js +33 -2
  53. package/lib/ws-schema.js +2 -0
  54. package/package.json +1 -1
@@ -0,0 +1,164 @@
1
+ // Shared role and target identity rules for Split Groups.
2
+ //
3
+ // Version 1 is the persisted two-session pair shape. Version 2 describes one
4
+ // Driver and up to two Workers, but callers must explicitly opt into it until
5
+ // all production consumers can address more than one Worker.
6
+ // Remaining direct pair.workerId reads are deliberate compatibility branches:
7
+ // session-split-groups owns legacy-only create and Driver-transfer transactions,
8
+ // while session-split-group-anchors and persistence resolve legacy records.
9
+ // Production activation must still migrate the client hierarchy, pane lock, and
10
+ // split-group helper projections before the server gate is enabled.
11
+
12
+ var MAX_WORKERS = 2;
13
+
14
+ function isInteger(value) {
15
+ return Number.isInteger(value);
16
+ }
17
+
18
+ function containsOnce(values, value) {
19
+ var count = 0;
20
+ for (var i = 0; i < values.length; i++) if (values[i] === value) count++;
21
+ return count === 1;
22
+ }
23
+
24
+ function validateMembers(members) {
25
+ if (!Array.isArray(members) || (members.length !== 2 && members.length !== 3)) {
26
+ return { ok: false, error: "A split group requires exactly two sessions until multi-Worker consumers are enabled" };
27
+ }
28
+ for (var i = 0; i < members.length; i++) {
29
+ if (!isInteger(members[i])) return { ok: false, error: "Split group members must be distinct integer session ids" };
30
+ if (!containsOnce(members, members[i])) return { ok: false, error: "Split group members must be distinct integer session ids" };
31
+ }
32
+ return { ok: true };
33
+ }
34
+
35
+ function validateRoleIds(driverId, workerIds, members) {
36
+ if (!isInteger(driverId)) return { ok: false, error: "The Driver role must reference an integer session id" };
37
+ if (!Array.isArray(workerIds) || workerIds.length < 1 || workerIds.length > MAX_WORKERS) {
38
+ return { ok: false, error: "A configured role requires one to two Workers" };
39
+ }
40
+ if (!containsOnce(members, driverId)) return { ok: false, error: "The Driver role must reference exactly one split group member" };
41
+ var seen = [driverId];
42
+ for (var i = 0; i < workerIds.length; i++) {
43
+ if (!isInteger(workerIds[i]) || seen.indexOf(workerIds[i]) !== -1 || !containsOnce(members, workerIds[i])) {
44
+ return { ok: false, error: "Worker roles must reference distinct split group members" };
45
+ }
46
+ seen.push(workerIds[i]);
47
+ }
48
+ if (seen.length !== members.length) return { ok: false, error: "Every split group member must have exactly one role" };
49
+ return { ok: true };
50
+ }
51
+
52
+ function normalizePair(pair, members) {
53
+ var memberCheck = validateMembers(members);
54
+ if (!memberCheck.ok) return memberCheck;
55
+ if (pair == null) {
56
+ if (members.length !== 2) return { ok: false, error: "An ad-hoc split group requires exactly two sessions" };
57
+ return { ok: true, kind: "adhoc", version: null, driverId: null, workerIds: [] };
58
+ }
59
+ if (!pair || typeof pair !== "object" || Array.isArray(pair)) return { ok: false, error: "Split group roles must be an object" };
60
+
61
+ var hasVersion = Object.prototype.hasOwnProperty.call(pair, "version");
62
+ var hasLegacyWorker = Object.prototype.hasOwnProperty.call(pair, "workerId");
63
+ var hasWorkerIds = Object.prototype.hasOwnProperty.call(pair, "workerIds");
64
+ if (hasVersion && pair.version !== 2) return { ok: false, error: "Unknown split group role version" };
65
+ if (hasVersion && hasLegacyWorker) return { ok: false, error: "Versioned roles cannot also define legacy workerId" };
66
+ if (hasVersion && !hasWorkerIds) return { ok: false, error: "Version 2 roles require workerIds" };
67
+ if (!hasVersion && hasWorkerIds) return { ok: false, error: "workerIds requires version 2 roles" };
68
+ if (!hasVersion && !hasLegacyWorker) return { ok: false, error: "Legacy roles require workerId" };
69
+
70
+ var workerIds = hasVersion ? pair.workerIds : [pair.workerId];
71
+ var checked = validateRoleIds(pair.driverId, workerIds, members);
72
+ if (!checked.ok) return checked;
73
+ return {
74
+ ok: true,
75
+ kind: hasVersion ? "versioned" : "legacy",
76
+ version: hasVersion ? 2 : null,
77
+ driverId: pair.driverId,
78
+ workerIds: workerIds.slice(),
79
+ };
80
+ }
81
+
82
+ function validateStorePair(pair, members) {
83
+ var normalized = normalizePair(pair, members);
84
+ if (!normalized.ok) return normalized;
85
+ if (normalized.kind === "versioned") {
86
+ return { ok: false, error: "Version 2 split group roles are reserved until multi-Worker consumers are migrated" };
87
+ }
88
+ if (normalized.kind === "adhoc" && members.length !== 2) {
89
+ return { ok: false, error: "An ad-hoc split group requires exactly two sessions" };
90
+ }
91
+ return normalized;
92
+ }
93
+
94
+ function isUnsupportedPair(pair) {
95
+ return !!(pair && typeof pair === "object" && Object.prototype.hasOwnProperty.call(pair, "version"));
96
+ }
97
+
98
+ function validateStoredGroup(group, sessions) {
99
+ if (!group || typeof group.id !== "string" || !Array.isArray(group.members) || group.members.length !== 2) return false;
100
+ if (!sessions.has(group.members[0]) || !sessions.has(group.members[1])) return false;
101
+ return validateStorePair(group.pair, group.members).ok;
102
+ }
103
+
104
+ function resolveWorkerTarget(role, requestedWorkerId) {
105
+ if (!role || !role.ok || role.kind === "adhoc") return { ok: false, error: "The split group has no configured Workers" };
106
+ if (requestedWorkerId != null) {
107
+ if (!isInteger(requestedWorkerId) || role.workerIds.indexOf(requestedWorkerId) === -1) {
108
+ return { ok: false, error: "workerId must identify an exact configured Worker" };
109
+ }
110
+ return { ok: true, workerId: requestedWorkerId };
111
+ }
112
+ if (role.workerIds.length !== 1) return { ok: false, error: "workerId is required when more than one Worker is configured" };
113
+ return { ok: true, workerId: role.workerIds[0] };
114
+ }
115
+
116
+ function resolveLiveRoles(sm, group) {
117
+ var normalized = normalizePair(group && group.pair, group && group.members);
118
+ if (!normalized.ok || normalized.kind === "adhoc") return null;
119
+ var driver = sm.sessions.get(normalized.driverId);
120
+ if (!driver || sm.sessions.get(driver.localId) !== driver || driver.destroying) return null;
121
+ var workers = [];
122
+ for (var i = 0; i < normalized.workerIds.length; i++) {
123
+ var worker = sm.sessions.get(normalized.workerIds[i]);
124
+ if (!worker || sm.sessions.get(worker.localId) !== worker || worker.destroying) return null;
125
+ if ((worker.ownerId || null) !== (driver.ownerId || null)) return null;
126
+ workers.push(worker);
127
+ }
128
+ return { group: group, role: normalized, driver: driver, workers: workers };
129
+ }
130
+
131
+ function matchesLiveTarget(sm, group, caller, worker) {
132
+ if (!group || !caller || !worker) return false;
133
+ if (sm.sessions.get(caller.localId) !== caller || sm.sessions.get(worker.localId) !== worker) return false;
134
+ if ((caller.ownerId || null) !== (worker.ownerId || null)) return false;
135
+ var normalized = normalizePair(group.pair, group.members);
136
+ if (!normalized.ok) return false;
137
+ if (normalized.kind === "adhoc") {
138
+ return group.members.indexOf(caller.localId) !== -1 && group.members.indexOf(worker.localId) !== -1 && caller !== worker;
139
+ }
140
+ var roles = resolveLiveRoles(sm, group);
141
+ return !!(roles && roles.driver === caller && roles.workers.indexOf(worker) !== -1);
142
+ }
143
+
144
+ function taskGeneration(worker) {
145
+ return worker && (worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation) || null;
146
+ }
147
+
148
+ function tokenMatchesWorker(worker, token) {
149
+ return !!(worker && token && token.workerSessionId === worker.localId && token.generation === taskGeneration(worker));
150
+ }
151
+
152
+ module.exports = {
153
+ MAX_WORKERS: MAX_WORKERS,
154
+ normalizePair: normalizePair,
155
+ matchesLiveTarget: matchesLiveTarget,
156
+ resolveLiveRoles: resolveLiveRoles,
157
+ resolveWorkerTarget: resolveWorkerTarget,
158
+ tokenMatchesWorker: tokenMatchesWorker,
159
+ validateMembers: validateMembers,
160
+ validateRoleIds: validateRoleIds,
161
+ validateStoredGroup: validateStoredGroup,
162
+ validateStorePair: validateStorePair,
163
+ isUnsupportedPair: isUnsupportedPair,
164
+ };
@@ -0,0 +1,146 @@
1
+ var multiWorkerFeature = require("./multi-worker-feature");
2
+ var splitRoles = require("./session-split-group-roles");
3
+
4
+ function sameOwner(session, ownerId) {
5
+ return !!session && (session.ownerId || null) === (ownerId || null);
6
+ }
7
+
8
+ function generationOf(session) {
9
+ return session && (session._pairGeneration ||
10
+ session.sessionProvenance && session.sessionProvenance.generation) || null;
11
+ }
12
+
13
+ function clone(value) { return JSON.parse(JSON.stringify(value)); }
14
+
15
+ function replace(target, source) {
16
+ var keys = Object.keys(target);
17
+ for (var i = 0; i < keys.length; i++) delete target[keys[i]];
18
+ Object.assign(target, clone(source));
19
+ }
20
+
21
+ function attachVersionedMutations(ctx) {
22
+ function authorized(feature) {
23
+ return ctx.enabled && multiWorkerFeature.isEnabled(feature);
24
+ }
25
+
26
+ function rejectCapability(capability) {
27
+ if (authorized(capability)) return null;
28
+ return { ok: false, error: "Version 2 split groups require the server multi-Worker feature" };
29
+ }
30
+
31
+ function exactGroup(ownerId, msg, targetRequired) {
32
+ var group = ctx.groups().find(function (item) { return item.id === (msg && msg.groupId); });
33
+ if (!group) return { error: "Split group not found" };
34
+ if (msg.expectedGroup && msg.expectedGroup !== group) return { error: "The exact split group changed" };
35
+ if ((group.ownerId || null) !== (ownerId || null)) return { error: "Split group owner changed" };
36
+ var normalized = splitRoles.normalizePair(group.pair, group.members);
37
+ if (!normalized.ok || normalized.kind === "adhoc") return { error: "Split group roles changed" };
38
+ if (normalized.driverId !== msg.driverId) return { error: "The configured Driver changed" };
39
+ if (Array.isArray(msg.expectedWorkerIds) &&
40
+ normalized.workerIds.join(",") !== msg.expectedWorkerIds.join(",")) {
41
+ return { error: "The configured Worker membership changed" };
42
+ }
43
+ var driver = ctx.sessions.get(msg.driverId);
44
+ if (!sameOwner(driver, ownerId) || ctx.sessions.get(driver.localId) !== driver ||
45
+ (msg.driverOriginId || null) !== (driver.sessionOriginId || null)) {
46
+ return { error: "The exact Driver identity changed" };
47
+ }
48
+ var target = null;
49
+ if (targetRequired) {
50
+ if (normalized.workerIds.indexOf(msg.workerId) === -1) return { error: "The selected Worker changed" };
51
+ target = ctx.sessions.get(msg.workerId);
52
+ if (!sameOwner(target, ownerId) || ctx.sessions.get(target.localId) !== target ||
53
+ (msg.workerOriginId || null) !== (target.sessionOriginId || null) ||
54
+ msg.generation !== generationOf(target)) {
55
+ return { error: "The exact Worker identity or generation changed" };
56
+ }
57
+ }
58
+ return { group: group, normalized: normalized, driver: driver, target: target };
59
+ }
60
+
61
+ function newWorker(ownerId, msg) {
62
+ var worker = ctx.sessions.get(msg.newWorkerId);
63
+ if (!sameOwner(worker, ownerId) || ctx.sessions.get(worker.localId) !== worker ||
64
+ (msg.newWorkerOriginId || null) !== (worker.sessionOriginId || null)) {
65
+ return { error: "The exact replacement Worker identity changed" };
66
+ }
67
+ if (ctx.membershipGroup(worker.localId)) return { error: "The Worker already belongs to a split group" };
68
+ return { worker: worker };
69
+ }
70
+
71
+ function commit(group, nextMembers, nextPair, hooks) {
72
+ var before = clone(group);
73
+ group.members = nextMembers;
74
+ group.pair = nextPair;
75
+ try { ctx.save(); }
76
+ catch (error) {
77
+ replace(group, before);
78
+ return { ok: false, error: error.message || String(error) };
79
+ }
80
+ var committed = clone(group);
81
+ if (hooks && typeof hooks.afterPersist === "function") {
82
+ replace(group, before);
83
+ var accepted;
84
+ try { accepted = hooks.afterPersist(); }
85
+ catch (error) { accepted = { ok: false, error: error.message || String(error) }; }
86
+ if (!accepted || accepted.ok === false) {
87
+ try { ctx.save(); }
88
+ catch (rollbackError) {
89
+ return { ok: false, error: (accepted && accepted.error || "close transaction rejected") +
90
+ "; persistence rollback failed: " + (rollbackError.message || String(rollbackError)) };
91
+ }
92
+ return { ok: false, error: accepted && accepted.error || "close transaction rejected" };
93
+ }
94
+ replace(group, committed);
95
+ }
96
+ ctx.changed(group);
97
+ return { ok: true, group: group };
98
+ }
99
+
100
+ function addWorker(ownerId, msg, capability) {
101
+ var denied = rejectCapability(capability);
102
+ if (denied) return denied;
103
+ var exact = exactGroup(ownerId, msg || {}, false);
104
+ if (exact.error) return { ok: false, error: exact.error };
105
+ if (exact.normalized.workerIds.length >= splitRoles.MAX_WORKERS) {
106
+ return { ok: false, error: "A Driver can have at most two Workers" };
107
+ }
108
+ var added = newWorker(ownerId, msg);
109
+ if (added.error) return { ok: false, error: added.error };
110
+ var workerIds = exact.normalized.workerIds.concat([added.worker.localId]);
111
+ return commit(exact.group, [exact.driver.localId].concat(workerIds), {
112
+ version: 2, driverId: exact.driver.localId, workerIds: workerIds,
113
+ });
114
+ }
115
+
116
+ function replaceWorker(ownerId, msg, capability) {
117
+ var denied = rejectCapability(capability);
118
+ if (denied) return denied;
119
+ var exact = exactGroup(ownerId, msg || {}, true);
120
+ if (exact.error) return { ok: false, error: exact.error };
121
+ var added = newWorker(ownerId, msg);
122
+ if (added.error) return { ok: false, error: added.error };
123
+ var workerIds = exact.normalized.workerIds.slice();
124
+ workerIds[workerIds.indexOf(exact.target.localId)] = added.worker.localId;
125
+ var pair = exact.normalized.kind === "legacy"
126
+ ? { driverId: exact.driver.localId, workerId: added.worker.localId }
127
+ : { version: 2, driverId: exact.driver.localId, workerIds: workerIds };
128
+ return commit(exact.group, [exact.driver.localId].concat(workerIds), pair);
129
+ }
130
+
131
+ function removeWorker(ownerId, msg, capability, hooks) {
132
+ var denied = rejectCapability(capability);
133
+ if (denied) return denied;
134
+ var exact = exactGroup(ownerId, msg || {}, true);
135
+ if (exact.error) return { ok: false, error: exact.error };
136
+ var workerIds = exact.normalized.workerIds.filter(function (id) { return id !== exact.target.localId; });
137
+ if (workerIds.length === 0) return ctx.remove(exact.group, hooks);
138
+ return commit(exact.group, [exact.driver.localId].concat(workerIds), {
139
+ version: 2, driverId: exact.driver.localId, workerIds: workerIds,
140
+ }, hooks);
141
+ }
142
+
143
+ return { addWorker: addWorker, replaceWorker: replaceWorker, removeWorker: removeWorker };
144
+ }
145
+
146
+ module.exports = { attachVersionedMutations: attachVersionedMutations, generationOf: generationOf };
@@ -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
- function refreshPairAnchors(group) {
21
- if (!group.pair) return;
22
- var anchors = durableAnchors.pairAnchors(group, sessions);
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
- 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" };
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 before = groups.length;
413
- groups = groups.filter(function (group) { return group.members.indexOf(localId) === -1; });
414
- if (groups.length === before) return false;
415
- 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
+ }
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
- load();
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 };