clay-server 4.2.0 → 4.3.0-beta.1
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-access.js +54 -0
- package/lib/driver-continuation-lease.js +52 -0
- package/lib/driver-continuation-pair-transfer.js +86 -0
- package/lib/driver-continuation-pair.js +82 -0
- package/lib/driver-continuation-record.js +40 -0
- package/lib/driver-continuation-startup.js +68 -0
- package/lib/driver-continuation-transaction.js +61 -0
- package/lib/driver-continuation-trigger.js +110 -0
- package/lib/project-driver-continuation.js +499 -0
- package/lib/project-pair-lifecycle.js +73 -73
- package/lib/project-pair-replacement-state.js +5 -1
- package/lib/project-session-handoff.js +48 -55
- package/lib/project-user-message.js +14 -0
- package/lib/project-worker-proposal.js +1 -1
- package/lib/project.js +49 -2
- package/lib/public/css/driver-continuation.css +56 -0
- package/lib/public/css/overlays.css +44 -16
- package/lib/public/css/pane.css +9 -5
- package/lib/public/index.html +3 -3
- package/lib/public/modules/app-messages.js +14 -0
- package/lib/public/modules/driver-continuation-state.js +76 -0
- package/lib/public/modules/driver-continuation.js +260 -0
- package/lib/public/modules/permission-control.js +8 -3
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +77 -0
- package/lib/session-handoff-discovery.js +443 -0
- package/lib/session-handoff-mcp-server.js +32 -4
- package/lib/session-pair-prompts.js +5 -0
- package/lib/session-pair-turn-control.js +19 -3
- package/lib/session-split-group-anchors.js +44 -0
- package/lib/session-split-groups.js +209 -59
- package/lib/sessions.js +20 -2
- package/lib/ws-schema.js +6 -0
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
var MAX_CREATIONS_PER_TURN = 2;
|
|
10
10
|
var MAX_REPLACEMENTS_PER_TURN = 1;
|
|
11
|
+
var MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN = 2;
|
|
11
12
|
var MAX_OPERATION_ID_CHARS = 120;
|
|
12
13
|
|
|
13
14
|
function attachPairTurnControl(ctx) {
|
|
@@ -23,9 +24,13 @@ function attachPairTurnControl(ctx) {
|
|
|
23
24
|
stoppedWorkerId: null,
|
|
24
25
|
creations: 0,
|
|
25
26
|
replacements: 0,
|
|
27
|
+
failedReplacementAttempts: 0,
|
|
26
28
|
operations: Object.create(null),
|
|
27
29
|
};
|
|
28
30
|
}
|
|
31
|
+
if (!Number.isFinite(driver._pairTurnControl.failedReplacementAttempts)) {
|
|
32
|
+
driver._pairTurnControl.failedReplacementAttempts = 0;
|
|
33
|
+
}
|
|
29
34
|
return driver._pairTurnControl;
|
|
30
35
|
}
|
|
31
36
|
|
|
@@ -56,6 +61,7 @@ function attachPairTurnControl(ctx) {
|
|
|
56
61
|
state.stoppedWorkerId = null;
|
|
57
62
|
state.creations = 0;
|
|
58
63
|
state.replacements = 0;
|
|
64
|
+
state.failedReplacementAttempts = 0;
|
|
59
65
|
state.operations = Object.create(null);
|
|
60
66
|
return true;
|
|
61
67
|
}
|
|
@@ -88,20 +94,28 @@ function attachPairTurnControl(ctx) {
|
|
|
88
94
|
if (kind === "replace" && state.replacements >= MAX_REPLACEMENTS_PER_TURN) {
|
|
89
95
|
throw new Error("the Split Worker replacement limit for this human turn has been reached; wait for a new human message before replacing it again");
|
|
90
96
|
}
|
|
97
|
+
if (kind === "replace" && state.failedReplacementAttempts >= MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN) {
|
|
98
|
+
throw new Error("the Split Worker replacement failure limit for this human turn has been reached; wait for a new human message before retrying replacement");
|
|
99
|
+
}
|
|
91
100
|
if (state.creations >= MAX_CREATIONS_PER_TURN) {
|
|
92
101
|
throw new Error("the Split Worker creation limit for this human turn has been reached; wait for a new human message before creating another Worker");
|
|
93
102
|
}
|
|
94
103
|
state.creations += 1;
|
|
95
104
|
if (kind === "replace") state.replacements += 1;
|
|
96
|
-
return { driver: driver, kind: kind, active: true };
|
|
105
|
+
return { driver: driver, kind: kind, turnSerial: state.serial, active: true };
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
function releaseCreation(ticket) {
|
|
100
|
-
if (!ticket || !ticket.active
|
|
109
|
+
if (!ticket || !ticket.active) return;
|
|
101
110
|
ticket.active = false;
|
|
111
|
+
if (!liveSession(ticket.driver)) return;
|
|
102
112
|
var state = stateFor(ticket.driver);
|
|
113
|
+
if (state.serial !== ticket.turnSerial) return;
|
|
103
114
|
state.creations = Math.max(0, state.creations - 1);
|
|
104
|
-
if (ticket.kind === "replace")
|
|
115
|
+
if (ticket.kind === "replace") {
|
|
116
|
+
state.replacements = Math.max(0, state.replacements - 1);
|
|
117
|
+
state.failedReplacementAttempts += 1;
|
|
118
|
+
}
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
function operationId(value) {
|
|
@@ -136,6 +150,7 @@ function attachPairTurnControl(ctx) {
|
|
|
136
150
|
turnSerial: state.serial,
|
|
137
151
|
creationsThisTurn: state.creations,
|
|
138
152
|
replacementsThisTurn: state.replacements,
|
|
153
|
+
failedReplacementAttemptsThisTurn: state.failedReplacementAttempts,
|
|
139
154
|
};
|
|
140
155
|
}
|
|
141
156
|
|
|
@@ -154,5 +169,6 @@ function attachPairTurnControl(ctx) {
|
|
|
154
169
|
module.exports = {
|
|
155
170
|
MAX_CREATIONS_PER_TURN: MAX_CREATIONS_PER_TURN,
|
|
156
171
|
MAX_REPLACEMENTS_PER_TURN: MAX_REPLACEMENTS_PER_TURN,
|
|
172
|
+
MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN: MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN,
|
|
157
173
|
attachPairTurnControl: attachPairTurnControl,
|
|
158
174
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
function value(input) {
|
|
2
|
+
return typeof input === "string" && input ? input : null;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function pairFor(session, previousCli, previousOrigin) {
|
|
6
|
+
return {
|
|
7
|
+
cli: value(session && session.cliSessionId) || value(previousCli),
|
|
8
|
+
origin: value(session && session.sessionOriginId) || value(previousOrigin),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function memberAnchors(group, sessions) {
|
|
13
|
+
var previousCli = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
|
|
14
|
+
var previousOrigin = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
|
|
15
|
+
var left = pairFor(sessions.get(group.members[0]), previousCli[0], previousOrigin[0]);
|
|
16
|
+
var right = pairFor(sessions.get(group.members[1]), previousCli[1], previousOrigin[1]);
|
|
17
|
+
return { cli: [left.cli, right.cli], origin: [left.origin, right.origin] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pairAnchors(group, sessions) {
|
|
21
|
+
if (!group.pair) return null;
|
|
22
|
+
var previousCli = Array.isArray(group.pairCliIds) ? group.pairCliIds : [null, null];
|
|
23
|
+
var previousOrigin = Array.isArray(group.pairOriginIds) ? group.pairOriginIds : [null, null];
|
|
24
|
+
var driver = pairFor(sessions.get(group.pair.driverId), previousCli[0], previousOrigin[0]);
|
|
25
|
+
var worker = pairFor(sessions.get(group.pair.workerId), previousCli[1], previousOrigin[1]);
|
|
26
|
+
return { cli: [driver.cli, worker.cli], origin: [driver.origin, worker.origin] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findSession(sessions, cliId, originId) {
|
|
30
|
+
var cli = value(cliId);
|
|
31
|
+
var origin = value(originId);
|
|
32
|
+
if (!cli && !origin) return null;
|
|
33
|
+
var found = null;
|
|
34
|
+
var ambiguous = false;
|
|
35
|
+
sessions.forEach(function (session) {
|
|
36
|
+
if (cli && session.cliSessionId !== cli) return;
|
|
37
|
+
if (origin && session.sessionOriginId !== origin) return;
|
|
38
|
+
if (found && found !== session) ambiguous = true;
|
|
39
|
+
else found = session;
|
|
40
|
+
});
|
|
41
|
+
return ambiguous ? null : found;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { findSession: findSession, memberAnchors: memberAnchors, pairAnchors: pairAnchors };
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
var crypto = require("crypto");
|
|
2
2
|
var fs = require("fs");
|
|
3
3
|
var path = require("path");
|
|
4
|
-
|
|
4
|
+
var durableAnchors = require("./session-split-group-anchors");
|
|
5
5
|
function truncateTitle(title) {
|
|
6
6
|
var value = String(title || "New Session");
|
|
7
7
|
return value.length > 20 ? value.slice(0, 19) + "…" : value;
|
|
8
8
|
}
|
|
9
|
-
|
|
10
9
|
function autoGroupName(leftTitle, rightTitle) {
|
|
11
10
|
return truncateTitle(leftTitle) + " | " + truncateTitle(rightTitle);
|
|
12
11
|
}
|
|
13
|
-
|
|
14
12
|
function createSplitGroupStore(opts) {
|
|
15
13
|
var sessions = opts.sessions;
|
|
16
14
|
var groupsFile = path.join(opts.sessionsDir, "split-groups.json");
|
|
@@ -18,61 +16,39 @@ function createSplitGroupStore(opts) {
|
|
|
18
16
|
var broadcast = opts.broadcast || function () {};
|
|
19
17
|
var onPairChanged = opts.onPairChanged || function () {};
|
|
20
18
|
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
|
-
|
|
19
|
+
var stagedTransfers = [];
|
|
36
20
|
function refreshPairAnchors(group) {
|
|
37
21
|
if (!group.pair) return;
|
|
38
|
-
var
|
|
39
|
-
|
|
40
|
-
group.
|
|
41
|
-
(driver && driver.cliSessionId) || (group.pairCliIds && group.pairCliIds[0]) || null,
|
|
42
|
-
(worker && worker.cliSessionId) || (group.pairCliIds && group.pairCliIds[1]) || null,
|
|
43
|
-
];
|
|
22
|
+
var anchors = durableAnchors.pairAnchors(group, sessions);
|
|
23
|
+
group.pairCliIds = anchors.cli;
|
|
24
|
+
group.pairOriginIds = anchors.origin;
|
|
44
25
|
}
|
|
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
26
|
function save() {
|
|
56
27
|
for (var gi = 0; gi < groups.length; gi++) {
|
|
57
|
-
|
|
28
|
+
var anchors = durableAnchors.memberAnchors(groups[gi], sessions);
|
|
29
|
+
groups[gi].memberCliIds = anchors.cli;
|
|
30
|
+
groups[gi].memberOriginIds = anchors.origin;
|
|
58
31
|
refreshPairAnchors(groups[gi]);
|
|
59
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
|
+
}
|
|
60
38
|
var tmp = groupsFile + ".tmp." + process.pid;
|
|
61
39
|
fs.mkdirSync(opts.sessionsDir, { recursive: true });
|
|
62
|
-
fs.writeFileSync(tmp,
|
|
40
|
+
fs.writeFileSync(tmp, serialized);
|
|
63
41
|
if (process.platform !== "win32") {
|
|
64
42
|
try { fs.chmodSync(tmp, 0o600); } catch (e) {}
|
|
65
43
|
}
|
|
66
44
|
fs.renameSync(tmp, groupsFile);
|
|
67
45
|
}
|
|
68
|
-
|
|
69
46
|
function validRecord(group) {
|
|
70
47
|
return group && typeof group.id === "string" && Array.isArray(group.members) &&
|
|
71
48
|
group.members.length === 2 && group.members[0] !== group.members[1] &&
|
|
72
49
|
Number.isInteger(group.members[0]) && Number.isInteger(group.members[1]) &&
|
|
73
50
|
sessions.has(group.members[0]) && sessions.has(group.members[1]);
|
|
74
51
|
}
|
|
75
|
-
|
|
76
52
|
function load() {
|
|
77
53
|
var loaded = [];
|
|
78
54
|
var shouldRewrite = false;
|
|
@@ -86,18 +62,22 @@ function createSplitGroupStore(opts) {
|
|
|
86
62
|
groups = loaded.filter(function (group) {
|
|
87
63
|
// Remap members from the durable cliSessionIds; the stored localIds
|
|
88
64
|
// are only trusted for legacy records that predate memberCliIds.
|
|
89
|
-
if (group && Array.isArray(group.memberCliIds)) {
|
|
90
|
-
var
|
|
91
|
-
var
|
|
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]);
|
|
92
70
|
if (!left || !right || left === right) return false;
|
|
93
71
|
if (group.members[0] !== left.localId || group.members[1] !== right.localId) {
|
|
94
72
|
group.members = [left.localId, right.localId];
|
|
95
73
|
shouldRewrite = true;
|
|
96
74
|
}
|
|
97
75
|
}
|
|
98
|
-
if (group && group.pair && Array.isArray(group.pairCliIds)) {
|
|
99
|
-
var
|
|
100
|
-
var
|
|
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]);
|
|
101
81
|
if (driver && worker) {
|
|
102
82
|
group.pair.driverId = driver.localId;
|
|
103
83
|
group.pair.workerId = worker.localId;
|
|
@@ -117,35 +97,51 @@ function createSplitGroupStore(opts) {
|
|
|
117
97
|
if (groups.length !== loaded.length) shouldRewrite = true;
|
|
118
98
|
if (shouldRewrite) save();
|
|
119
99
|
}
|
|
120
|
-
|
|
121
100
|
function isMultiUser() {
|
|
122
101
|
return !!(usersModule && usersModule.isMultiUser && usersModule.isMultiUser());
|
|
123
102
|
}
|
|
124
|
-
|
|
125
103
|
function listFor(ws) {
|
|
126
104
|
if (!isMultiUser()) return groups.slice();
|
|
127
105
|
if (!ws || !ws._clayUser) return [];
|
|
128
106
|
return groups.filter(function (group) { return group.ownerId === ws._clayUser.id; });
|
|
129
107
|
}
|
|
130
|
-
|
|
131
108
|
function groupForMember(localId) {
|
|
109
|
+
for (var si = 0; si < stagedTransfers.length; si++) {
|
|
110
|
+
var transfer = stagedTransfers[si];
|
|
111
|
+
if (!exactTransfer(transfer)) continue;
|
|
112
|
+
if (transfer.source.localId === localId) return null;
|
|
113
|
+
if (transfer.target.localId === localId || transfer.worker.localId === localId) return transfer.runtimeGroup;
|
|
114
|
+
}
|
|
132
115
|
for (var i = 0; i < groups.length; i++) {
|
|
133
116
|
if (groups[i].members.indexOf(localId) !== -1) return groups[i];
|
|
134
117
|
}
|
|
135
118
|
return null;
|
|
136
119
|
}
|
|
137
|
-
|
|
120
|
+
function membershipGroup(localId) {
|
|
121
|
+
for (var si = 0; si < stagedTransfers.length; si++) {
|
|
122
|
+
var transfer = stagedTransfers[si];
|
|
123
|
+
if (transfer.source.localId === localId || transfer.target.localId === localId ||
|
|
124
|
+
transfer.worker.localId === localId) return transfer.group;
|
|
125
|
+
}
|
|
126
|
+
for (var i = 0; i < groups.length; i++) if (groups[i].members.indexOf(localId) !== -1) return groups[i];
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function groupCountForMember(localId) {
|
|
130
|
+
var count = 0;
|
|
131
|
+
for (var i = 0; i < groups.length; i++) {
|
|
132
|
+
if (groups[i].members.indexOf(localId) !== -1) count++;
|
|
133
|
+
}
|
|
134
|
+
return count;
|
|
135
|
+
}
|
|
138
136
|
function canAccess(ws, session) {
|
|
139
137
|
if (!isMultiUser()) return true;
|
|
140
138
|
return !!(ws && ws._clayUser && usersModule.canAccessSession(
|
|
141
139
|
ws._clayUser.id, session, { visibility: "public" }
|
|
142
140
|
));
|
|
143
141
|
}
|
|
144
|
-
|
|
145
142
|
function canOwn(ws, group) {
|
|
146
143
|
return !isMultiUser() || !!(ws && ws._clayUser && group.ownerId === ws._clayUser.id);
|
|
147
144
|
}
|
|
148
|
-
|
|
149
145
|
function create(ws, msg) {
|
|
150
146
|
var members = msg && msg.members;
|
|
151
147
|
if (!Array.isArray(members) || members.length !== 2) return { ok: false, error: "A split group requires exactly two sessions" };
|
|
@@ -156,7 +152,7 @@ function createSplitGroupStore(opts) {
|
|
|
156
152
|
var right = sessions.get(members[1]);
|
|
157
153
|
if (!left || !right) return { ok: false, error: "Session not found" };
|
|
158
154
|
if (!canAccess(ws, left) || !canAccess(ws, right)) return { ok: false, error: "Session access denied" };
|
|
159
|
-
if (
|
|
155
|
+
if (membershipGroup(members[0]) || membershipGroup(members[1])) return { ok: false, error: "A session can belong to only one split group" };
|
|
160
156
|
var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
|
|
161
157
|
var group = {
|
|
162
158
|
id: "sg_" + Date.now().toString(36) + "_" + crypto.randomBytes(3).toString("hex"),
|
|
@@ -178,7 +174,6 @@ function createSplitGroupStore(opts) {
|
|
|
178
174
|
if (group.pair) onPairChanged(group);
|
|
179
175
|
return { ok: true, group: group };
|
|
180
176
|
}
|
|
181
|
-
|
|
182
177
|
function createOwned(ownerId, msg) {
|
|
183
178
|
var members = msg && msg.members;
|
|
184
179
|
if (!Array.isArray(members) || members.length !== 2) return { ok: false, error: "A split group requires exactly two sessions" };
|
|
@@ -186,7 +181,7 @@ function createSplitGroupStore(opts) {
|
|
|
186
181
|
var right = sessions.get(members[1]);
|
|
187
182
|
if (!left || !right || left === right) return { ok: false, error: "Session not found" };
|
|
188
183
|
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 (
|
|
184
|
+
if (membershipGroup(left.localId) || membershipGroup(right.localId)) return { ok: false, error: "A session can belong to only one split group" };
|
|
190
185
|
var pair = msg && msg.pair;
|
|
191
186
|
if (!pair || pair.driverId !== left.localId || pair.workerId !== right.localId) return { ok: false, error: "Pair roles must reference both split group members" };
|
|
192
187
|
var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
|
|
@@ -202,7 +197,6 @@ function createSplitGroupStore(opts) {
|
|
|
202
197
|
onPairChanged(group);
|
|
203
198
|
return { ok: true, group: group };
|
|
204
199
|
}
|
|
205
|
-
|
|
206
200
|
function dissolveOwned(ownerId, id) {
|
|
207
201
|
var index = groups.findIndex(function (item) { return item.id === id && (item.ownerId || null) === (ownerId || null); });
|
|
208
202
|
if (index === -1) return false;
|
|
@@ -212,7 +206,157 @@ function createSplitGroupStore(opts) {
|
|
|
212
206
|
onPairChanged(removed);
|
|
213
207
|
return true;
|
|
214
208
|
}
|
|
215
|
-
|
|
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
|
+
function beginOwnedDriverTransfer(ownerId, msg) {
|
|
213
|
+
var group = groups.find(function (item) { return item.id === (msg && msg.id); });
|
|
214
|
+
if (!group || (group.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Split group not found" };
|
|
215
|
+
if (!group.pair || group.pair.driverId !== msg.sourceDriverId || group.pair.workerId !== msg.workerId) {
|
|
216
|
+
return { ok: false, error: "The split pair changed before Driver transfer" };
|
|
217
|
+
}
|
|
218
|
+
var source = sessions.get(msg.sourceDriverId);
|
|
219
|
+
var target = sessions.get(msg.targetDriverId);
|
|
220
|
+
var worker = sessions.get(msg.workerId);
|
|
221
|
+
if (!source || !target || !worker || source === target || target === worker) return { ok: false, error: "Session not found" };
|
|
222
|
+
if ((source.ownerId || null) !== (ownerId || null) || (target.ownerId || null) !== (ownerId || null) ||
|
|
223
|
+
(worker.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Split pair owner changed" };
|
|
224
|
+
if (membershipGroup(target.localId)) return { ok: false, error: "The successor already belongs to a split group" };
|
|
225
|
+
var sourceIndex = group.members.indexOf(source.localId);
|
|
226
|
+
if (sourceIndex === -1 || group.members.indexOf(worker.localId) === -1) return { ok: false, error: "Split group members changed" };
|
|
227
|
+
var before = {
|
|
228
|
+
members: group.members.slice(),
|
|
229
|
+
memberCliIds: Array.isArray(group.memberCliIds) ? group.memberCliIds.slice() : null,
|
|
230
|
+
memberOriginIds: Array.isArray(group.memberOriginIds) ? group.memberOriginIds.slice() : null,
|
|
231
|
+
pair: Object.assign({}, group.pair),
|
|
232
|
+
pairCliIds: Array.isArray(group.pairCliIds) ? group.pairCliIds.slice() : null,
|
|
233
|
+
pairOriginIds: Array.isArray(group.pairOriginIds) ? group.pairOriginIds.slice() : null,
|
|
234
|
+
};
|
|
235
|
+
var runtimeMembers = group.members.slice();
|
|
236
|
+
runtimeMembers[sourceIndex] = target.localId;
|
|
237
|
+
var runtimeGroup = Object.assign({}, group, { members: runtimeMembers,
|
|
238
|
+
pair: { driverId: target.localId, workerId: worker.localId } });
|
|
239
|
+
var transaction = { group: group, before: before, source: source, target: target, worker: worker,
|
|
240
|
+
runtimeGroup: runtimeGroup,
|
|
241
|
+
ownerId: ownerId || null, sourceOriginId: source.sessionOriginId || null,
|
|
242
|
+
targetOriginId: target.sessionOriginId || null, workerOriginId: worker.sessionOriginId || null,
|
|
243
|
+
workerCliSessionId: worker.cliSessionId || null, staged: true, committed: false };
|
|
244
|
+
stagedTransfers.push(transaction);
|
|
245
|
+
return { ok: true, group: runtimeGroup, transaction: transaction };
|
|
246
|
+
}
|
|
247
|
+
function removeStaged(transaction) {
|
|
248
|
+
var index = stagedTransfers.indexOf(transaction);
|
|
249
|
+
if (index !== -1) stagedTransfers.splice(index, 1);
|
|
250
|
+
}
|
|
251
|
+
function restoreTransfer(transaction) {
|
|
252
|
+
var group = transaction.group;
|
|
253
|
+
group.members = transaction.before.members.slice();
|
|
254
|
+
group.pair = Object.assign({}, transaction.before.pair);
|
|
255
|
+
if (transaction.before.memberCliIds) group.memberCliIds = transaction.before.memberCliIds.slice();
|
|
256
|
+
else delete group.memberCliIds;
|
|
257
|
+
if (transaction.before.memberOriginIds) group.memberOriginIds = transaction.before.memberOriginIds.slice();
|
|
258
|
+
else delete group.memberOriginIds;
|
|
259
|
+
if (transaction.before.pairCliIds) group.pairCliIds = transaction.before.pairCliIds.slice();
|
|
260
|
+
else delete group.pairCliIds;
|
|
261
|
+
if (transaction.before.pairOriginIds) group.pairOriginIds = transaction.before.pairOriginIds.slice();
|
|
262
|
+
else delete group.pairOriginIds;
|
|
263
|
+
}
|
|
264
|
+
function stagedTransfer(transaction) {
|
|
265
|
+
return transaction && transaction.staged && !transaction.committed &&
|
|
266
|
+
stagedTransfers.indexOf(transaction) !== -1 && groups.indexOf(transaction.group) !== -1 &&
|
|
267
|
+
transaction.runtimeGroup.pair && transaction.runtimeGroup.pair.driverId === transaction.target.localId &&
|
|
268
|
+
transaction.runtimeGroup.pair.workerId === transaction.worker.localId &&
|
|
269
|
+
transaction.runtimeGroup.members.length === 2 && transaction.runtimeGroup.members.indexOf(transaction.target.localId) !== -1 &&
|
|
270
|
+
transaction.runtimeGroup.members.indexOf(transaction.worker.localId) !== -1;
|
|
271
|
+
}
|
|
272
|
+
function exactTransfer(transaction) {
|
|
273
|
+
return stagedTransfer(transaction) &&
|
|
274
|
+
sessions.get(transaction.source.localId) === transaction.source &&
|
|
275
|
+
sessions.get(transaction.target.localId) === transaction.target &&
|
|
276
|
+
sessions.get(transaction.worker.localId) === transaction.worker &&
|
|
277
|
+
(transaction.group.ownerId || null) === transaction.ownerId &&
|
|
278
|
+
(transaction.source.ownerId || null) === transaction.ownerId &&
|
|
279
|
+
(transaction.target.ownerId || null) === transaction.ownerId &&
|
|
280
|
+
(transaction.worker.ownerId || null) === transaction.ownerId &&
|
|
281
|
+
(transaction.source.sessionOriginId || null) === transaction.sourceOriginId &&
|
|
282
|
+
(transaction.target.sessionOriginId || null) === transaction.targetOriginId &&
|
|
283
|
+
(transaction.worker.sessionOriginId || null) === transaction.workerOriginId &&
|
|
284
|
+
(transaction.worker.cliSessionId || null) === transaction.workerCliSessionId &&
|
|
285
|
+
transaction.group.pair && transaction.group.pair.driverId === transaction.source.localId &&
|
|
286
|
+
transaction.group.pair.workerId === transaction.worker.localId &&
|
|
287
|
+
transaction.group.members.length === 2 && transaction.group.members.indexOf(transaction.source.localId) !== -1 &&
|
|
288
|
+
transaction.group.members.indexOf(transaction.worker.localId) !== -1 &&
|
|
289
|
+
groupCountForMember(transaction.target.localId) === 0 && groupCountForMember(transaction.worker.localId) === 1 &&
|
|
290
|
+
groupCountForMember(transaction.source.localId) === 1;
|
|
291
|
+
}
|
|
292
|
+
function commitOwnedDriverTransfer(transaction) {
|
|
293
|
+
if (!exactTransfer(transaction)) {
|
|
294
|
+
removeStaged(transaction);
|
|
295
|
+
transaction.staged = false;
|
|
296
|
+
return { ok: false, error: "The staged Driver transfer is no longer exact" };
|
|
297
|
+
}
|
|
298
|
+
if (!transaction.target.cliSessionId ||
|
|
299
|
+
(!transaction.worker.cliSessionId && !transaction.worker.sessionOriginId)) {
|
|
300
|
+
return { ok: false, error: "The transferred pair does not have durable session anchors" };
|
|
301
|
+
}
|
|
302
|
+
transaction.group.members = transaction.runtimeGroup.members.slice();
|
|
303
|
+
transaction.group.pair = Object.assign({}, transaction.runtimeGroup.pair);
|
|
304
|
+
try { save(); }
|
|
305
|
+
catch (error) {
|
|
306
|
+
restoreTransfer(transaction);
|
|
307
|
+
removeStaged(transaction);
|
|
308
|
+
transaction.staged = false;
|
|
309
|
+
return { ok: false, error: error.message || String(error) };
|
|
310
|
+
}
|
|
311
|
+
removeStaged(transaction);
|
|
312
|
+
transaction.committed = true;
|
|
313
|
+
broadcast();
|
|
314
|
+
onPairChanged(transaction.group);
|
|
315
|
+
return { ok: true, group: transaction.group };
|
|
316
|
+
}
|
|
317
|
+
function rollbackOwnedDriverTransfer(transaction) {
|
|
318
|
+
if (stagedTransfer(transaction)) {
|
|
319
|
+
removeStaged(transaction);
|
|
320
|
+
transaction.staged = false;
|
|
321
|
+
return { ok: true, group: transaction.group };
|
|
322
|
+
}
|
|
323
|
+
if (!transaction || !transaction.committed || groups.indexOf(transaction.group) === -1 ||
|
|
324
|
+
transaction.group.pair.driverId !== transaction.target.localId ||
|
|
325
|
+
transaction.group.pair.workerId !== transaction.worker.localId) {
|
|
326
|
+
return { ok: false, error: "The staged Driver transfer is no longer exact" };
|
|
327
|
+
}
|
|
328
|
+
var wasCommitted = transaction.committed;
|
|
329
|
+
var committedState = {
|
|
330
|
+
members: transaction.group.members.slice(),
|
|
331
|
+
memberCliIds: Array.isArray(transaction.group.memberCliIds) ? transaction.group.memberCliIds.slice() : null,
|
|
332
|
+
memberOriginIds: Array.isArray(transaction.group.memberOriginIds) ? transaction.group.memberOriginIds.slice() : null,
|
|
333
|
+
pair: Object.assign({}, transaction.group.pair),
|
|
334
|
+
pairCliIds: Array.isArray(transaction.group.pairCliIds) ? transaction.group.pairCliIds.slice() : null,
|
|
335
|
+
pairOriginIds: Array.isArray(transaction.group.pairOriginIds) ? transaction.group.pairOriginIds.slice() : null,
|
|
336
|
+
};
|
|
337
|
+
restoreTransfer(transaction);
|
|
338
|
+
if (wasCommitted) {
|
|
339
|
+
try { save(); }
|
|
340
|
+
catch (error) {
|
|
341
|
+
transaction.group.members = committedState.members;
|
|
342
|
+
transaction.group.pair = committedState.pair;
|
|
343
|
+
if (committedState.memberCliIds) transaction.group.memberCliIds = committedState.memberCliIds;
|
|
344
|
+
else delete transaction.group.memberCliIds;
|
|
345
|
+
if (committedState.memberOriginIds) transaction.group.memberOriginIds = committedState.memberOriginIds;
|
|
346
|
+
else delete transaction.group.memberOriginIds;
|
|
347
|
+
if (committedState.pairCliIds) transaction.group.pairCliIds = committedState.pairCliIds;
|
|
348
|
+
else delete transaction.group.pairCliIds;
|
|
349
|
+
if (committedState.pairOriginIds) transaction.group.pairOriginIds = committedState.pairOriginIds;
|
|
350
|
+
else delete transaction.group.pairOriginIds;
|
|
351
|
+
return { ok: false, error: error.message || String(error) };
|
|
352
|
+
}
|
|
353
|
+
broadcast();
|
|
354
|
+
onPairChanged(transaction.group);
|
|
355
|
+
}
|
|
356
|
+
transaction.staged = false;
|
|
357
|
+
transaction.committed = false;
|
|
358
|
+
return { ok: true, group: transaction.group };
|
|
359
|
+
}
|
|
216
360
|
function rename(ws, msg) {
|
|
217
361
|
var group = groups.find(function (item) { return item.id === (msg && msg.id); });
|
|
218
362
|
if (!group) return { ok: false, error: "Split group not found" };
|
|
@@ -237,6 +381,7 @@ function createSplitGroupStore(opts) {
|
|
|
237
381
|
if (msg.driverId == null) {
|
|
238
382
|
delete group.pair;
|
|
239
383
|
delete group.pairCliIds;
|
|
384
|
+
delete group.pairOriginIds;
|
|
240
385
|
save();
|
|
241
386
|
broadcast();
|
|
242
387
|
onPairChanged(group);
|
|
@@ -275,9 +420,11 @@ function createSplitGroupStore(opts) {
|
|
|
275
420
|
function refreshAnchors(localId) {
|
|
276
421
|
var group = groupForMember(localId);
|
|
277
422
|
if (!group) return false;
|
|
278
|
-
var fresh =
|
|
279
|
-
var
|
|
280
|
-
|
|
423
|
+
var fresh = durableAnchors.memberAnchors(group, sessions);
|
|
424
|
+
var prevCli = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
|
|
425
|
+
var prevOrigin = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
|
|
426
|
+
if (fresh.cli[0] === prevCli[0] && fresh.cli[1] === prevCli[1] &&
|
|
427
|
+
fresh.origin[0] === prevOrigin[0] && fresh.origin[1] === prevOrigin[1]) return false;
|
|
281
428
|
save();
|
|
282
429
|
return true;
|
|
283
430
|
}
|
|
@@ -299,7 +446,10 @@ function createSplitGroupStore(opts) {
|
|
|
299
446
|
}
|
|
300
447
|
|
|
301
448
|
load();
|
|
302
|
-
return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned,
|
|
449
|
+
return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned,
|
|
450
|
+
beginOwnedDriverTransfer: beginOwnedDriverTransfer, commitOwnedDriverTransfer: commitOwnedDriverTransfer,
|
|
451
|
+
rollbackOwnedDriverTransfer: rollbackOwnedDriverTransfer,
|
|
452
|
+
rename: rename, setPair: setPair, dissolve: dissolve, dissolveBySession: dissolveBySession,
|
|
303
453
|
refreshAutoName: refreshAutoName, refreshAnchors: refreshAnchors,
|
|
304
454
|
listFor: listFor, groupForMember: groupForMember, get groups() { return groups; } };
|
|
305
455
|
}
|
package/lib/sessions.js
CHANGED
|
@@ -24,6 +24,7 @@ function createSessionManager(opts) {
|
|
|
24
24
|
var sessionViewedListeners = [];
|
|
25
25
|
var onSessionRenamed = opts.onSessionRenamed || function () {};
|
|
26
26
|
var onSessionIdentityAssigned = opts.onSessionIdentityAssigned || function () {};
|
|
27
|
+
var sessionIdentityAssignedListeners = [];
|
|
27
28
|
var onSessionHydrate = opts.onSessionHydrate || null;
|
|
28
29
|
|
|
29
30
|
// --- Multi-session state ---
|
|
@@ -148,7 +149,7 @@ function createSessionManager(opts) {
|
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
function saveSessionFile(session) {
|
|
151
|
-
if (!session.cliSessionId) return;
|
|
152
|
+
if (!session.cliSessionId) return false;
|
|
152
153
|
try {
|
|
153
154
|
var metaObj = {
|
|
154
155
|
type: "meta",
|
|
@@ -194,6 +195,8 @@ function createSessionManager(opts) {
|
|
|
194
195
|
if (session.spawn) metaObj.spawn = session.spawn;
|
|
195
196
|
if (session.assignment) metaObj.assignment = session.assignment;
|
|
196
197
|
if (session.handoff) metaObj.handoff = session.handoff;
|
|
198
|
+
if (session.driverContinuation) metaObj.driverContinuation = session.driverContinuation;
|
|
199
|
+
if (session.driverContinuationDecline) metaObj.driverContinuationDecline = session.driverContinuationDecline;
|
|
197
200
|
if (session.debateState) metaObj.debateState = session.debateState;
|
|
198
201
|
if (session.debateSetupMode) metaObj.debateSetupMode = true;
|
|
199
202
|
if (session.homeDebatePlanning) metaObj.homeDebatePlanning = true;
|
|
@@ -219,8 +222,10 @@ function createSessionManager(opts) {
|
|
|
219
222
|
try { fs.chmodSync(tmpPath, 0o600); } catch (chmodErr) {}
|
|
220
223
|
}
|
|
221
224
|
fs.renameSync(tmpPath, sfPath);
|
|
225
|
+
return true;
|
|
222
226
|
} catch(e) {
|
|
223
227
|
console.error("[session] Failed to save session file:", e.message);
|
|
228
|
+
return false;
|
|
224
229
|
}
|
|
225
230
|
}
|
|
226
231
|
|
|
@@ -327,6 +332,8 @@ function createSessionManager(opts) {
|
|
|
327
332
|
if (m.spawn) session.spawn = m.spawn;
|
|
328
333
|
if (m.assignment) session.assignment = m.assignment;
|
|
329
334
|
if (m.handoff) session.handoff = m.handoff;
|
|
335
|
+
if (m.driverContinuation) session.driverContinuation = m.driverContinuation;
|
|
336
|
+
if (m.driverContinuationDecline) session.driverContinuationDecline = m.driverContinuationDecline;
|
|
330
337
|
if (m.debateState) session.debateState = m.debateState;
|
|
331
338
|
if (m.debateSetupMode) session.debateSetupMode = true;
|
|
332
339
|
if (m.homeDebatePlanning) session.homeDebatePlanning = true;
|
|
@@ -1340,8 +1347,19 @@ function createSessionManager(opts) {
|
|
|
1340
1347
|
addOnSessionViewed: function (fn) { if (typeof fn === "function") sessionViewedListeners.push(fn); },
|
|
1341
1348
|
setOnSessionRenamed: function (fn) { onSessionRenamed = fn || function () {}; },
|
|
1342
1349
|
setOnSessionIdentityAssigned: function (fn) { onSessionIdentityAssigned = fn || function () {}; },
|
|
1350
|
+
addOnSessionIdentityAssigned: function (fn) {
|
|
1351
|
+
if (typeof fn !== "function") return function () {};
|
|
1352
|
+
sessionIdentityAssignedListeners.push(fn);
|
|
1353
|
+
return function () {
|
|
1354
|
+
sessionIdentityAssignedListeners = sessionIdentityAssignedListeners.filter(function (listener) { return listener !== fn; });
|
|
1355
|
+
};
|
|
1356
|
+
},
|
|
1343
1357
|
notifySessionRenamed: function (localId) { onSessionRenamed(localId); },
|
|
1344
|
-
notifySessionIdentityAssigned: function (localId) {
|
|
1358
|
+
notifySessionIdentityAssigned: function (localId) {
|
|
1359
|
+
onSessionIdentityAssigned(localId);
|
|
1360
|
+
var listeners = sessionIdentityAssignedListeners.slice();
|
|
1361
|
+
for (var i = 0; i < listeners.length; i++) listeners[i](localId);
|
|
1362
|
+
},
|
|
1345
1363
|
HISTORY_PAGE_SIZE: HISTORY_PAGE_SIZE,
|
|
1346
1364
|
getActiveSession: getActiveSession,
|
|
1347
1365
|
createSession: createSession,
|
package/lib/ws-schema.js
CHANGED
|
@@ -44,6 +44,8 @@ var schema = {
|
|
|
44
44
|
"pair_session_options": { direction: "c2s", handler: "lib/project-session-pair.js", description: "Request vendors/models for the pair-session dialog (response reuses the same type)" },
|
|
45
45
|
"pair_session_create": { direction: "c2s", handler: "lib/project-session-pair.js", description: "Create a Driver/Split Worker session pair as a split group" },
|
|
46
46
|
"worker_proposal_response": { direction: "c2s", handler: "lib/project-worker-proposal.js", description: "Accept or decline an exact Driver Split Worker configuration with the selected runtime" },
|
|
47
|
+
"driver_continuation_response": { direction: "c2s", handler: "lib/project-driver-continuation.js", description: "Accept or decline an exact source-bound Driver continuation proposal" },
|
|
48
|
+
"driver_continuation_open_source": { direction: "c2s", handler: "lib/project-driver-continuation.js", description: "Resolve a successor's stable origin link to its authorized original Driver" },
|
|
47
49
|
"project_assignment_response": { direction: "c2s", handler: "lib/workspace-assignment-service.js", description: "Approve or cancel an exact-session cross-project assignment proposal" },
|
|
48
50
|
"home_clay_session_resolve": { direction: "c2s", handler: "lib/server-home-chat.js", description: "Resolve an opaque Mate session reference through the exact bound source session" },
|
|
49
51
|
"home_clay_session_target": { direction: "s2c", handler: "lib/public/modules/command-palette.js", description: "Return an owner-validated navigation target for a rendered Mate session reference" },
|
|
@@ -101,6 +103,10 @@ var schema = {
|
|
|
101
103
|
"pair_session_created": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Result of pair_session_create; carries the new group" },
|
|
102
104
|
"worker_proposal": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Recorded Split Worker creation/replacement runtime card with Driver rationale" },
|
|
103
105
|
"worker_proposal_update": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Worker card lifecycle and manual/auto decision audit update" },
|
|
106
|
+
"driver_continuation_proposal": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Recorded review card for a compact Driver continuation" },
|
|
107
|
+
"driver_continuation_update": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Durable exact-proposal continuation lifecycle update" },
|
|
108
|
+
"driver_continuation_result": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Correlated response to a continuation decision" },
|
|
109
|
+
"driver_continuation_context": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Compact inherited context and original-session link in a successor" },
|
|
104
110
|
"project_assignment_proposal": { direction: "s2c", handler: "lib/public/modules/app-message-router.js", description: "Approval card for a new owner-bound project assignment" },
|
|
105
111
|
"project_assignment_status": { direction: "s2c", handler: "lib/public/modules/app-message-router.js", description: "Durable project assignment lifecycle update" },
|
|
106
112
|
"project_assignment_error": { direction: "s2c", handler: "lib/public/modules/app-message-router.js", description: "Rejected or stale project assignment response" },
|
package/package.json
CHANGED