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.
- package/lib/driver-continuation-pair.js +5 -1
- package/lib/multi-worker-feature.js +11 -0
- package/lib/project-connection.js +2 -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-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/pane.css +48 -2
- package/lib/public/modules/app-connection.js +49 -76
- package/lib/public/modules/app-messages.js +11 -0
- package/lib/public/modules/pair-result-status.js +87 -0
- package/lib/public/modules/project-activation.js +10 -7
- package/lib/public/modules/sidebar-sessions.js +31 -11
- 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-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 +33 -2
- package/lib/ws-schema.js +2 -0
- 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
|
-
|
|
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 };
|