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
|
@@ -34,6 +34,7 @@ var driverEligibility = require("./session-driver-eligibility");
|
|
|
34
34
|
var models = require("./project-models");
|
|
35
35
|
var yoke = require("./yoke");
|
|
36
36
|
var sessionProvenance = require("./session-provenance");
|
|
37
|
+
var splitRoles = require("./session-split-group-roles");
|
|
37
38
|
|
|
38
39
|
function attachPairFactory(ctx) {
|
|
39
40
|
var sm = ctx.sm;
|
|
@@ -230,6 +231,69 @@ function attachPairFactory(ctx) {
|
|
|
230
231
|
return created;
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
function createUnpairedWorkerForDriver(driver, args) {
|
|
235
|
+
var runtime = preflightWorkerForDriver(driver, args);
|
|
236
|
+
var worker = sm.createSessionRaw({
|
|
237
|
+
ownerId: driver.ownerId || null,
|
|
238
|
+
vendor: runtime.vendor,
|
|
239
|
+
model: runtime.model,
|
|
240
|
+
effort: runtime.effort,
|
|
241
|
+
});
|
|
242
|
+
try {
|
|
243
|
+
worker.title = "Split Worker · " + ((yoke.getVendorInfo(runtime.vendor) || {}).displayName || runtime.vendor);
|
|
244
|
+
if (runtime.explicitEffort) worker.loopSettings = { effort: runtime.effort };
|
|
245
|
+
sessionProvenance.markWorker(driver, worker, sm.sessions);
|
|
246
|
+
if (typeof sm.saveSessionFile === "function") {
|
|
247
|
+
if (sm.saveSessionFile(driver) === false || sm.saveSessionFile(worker) === false) {
|
|
248
|
+
throw new Error("Split Worker session persistence returned false");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return worker;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
removeCreated(worker);
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function exactMutation(driver, worker, args, method) {
|
|
259
|
+
var group = store.groupForMember(driver.localId);
|
|
260
|
+
if (!group) throw new Error("split group not found");
|
|
261
|
+
var role = splitRoles.normalizePair(group.pair, group.members);
|
|
262
|
+
if (!role.ok || role.kind === "adhoc") throw new Error("split group roles changed");
|
|
263
|
+
var created = createUnpairedWorkerForDriver(driver, args);
|
|
264
|
+
var request = {
|
|
265
|
+
groupId: group.id,
|
|
266
|
+
driverId: driver.localId,
|
|
267
|
+
driverOriginId: driver.sessionOriginId || null,
|
|
268
|
+
expectedGroup: args.expectedGroup || group,
|
|
269
|
+
expectedWorkerIds: Array.isArray(args.expectedWorkerIds) ? args.expectedWorkerIds.slice() : role.workerIds.slice(),
|
|
270
|
+
newWorkerId: created.localId,
|
|
271
|
+
newWorkerOriginId: created.sessionOriginId || null,
|
|
272
|
+
};
|
|
273
|
+
if (worker) {
|
|
274
|
+
request.workerId = worker.localId;
|
|
275
|
+
request.workerOriginId = worker.sessionOriginId || null;
|
|
276
|
+
request.generation = worker._pairGeneration ||
|
|
277
|
+
worker.sessionProvenance && worker.sessionProvenance.generation || null;
|
|
278
|
+
}
|
|
279
|
+
var result = store[method](driver.ownerId || null, request, ctx.multiWorkerFeature);
|
|
280
|
+
if (!result.ok) {
|
|
281
|
+
removeCreated(created);
|
|
282
|
+
throw new Error(result.error);
|
|
283
|
+
}
|
|
284
|
+
sm.broadcastSessionList();
|
|
285
|
+
sm.sendToSession(driver, { type: "pair_session_created", ok: true, group: result.group });
|
|
286
|
+
return { driver: driver, worker: created, group: result.group };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function addWorkerForDriver(driver, args) {
|
|
290
|
+
return exactMutation(driver, null, args, "addWorker");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function replaceWorkerForDriver(driver, worker, args) {
|
|
294
|
+
return exactMutation(driver, worker, args, "replaceWorker");
|
|
295
|
+
}
|
|
296
|
+
|
|
233
297
|
function createOwnedPair(ownerId, args) {
|
|
234
298
|
if (ctx.isMate) throw new Error("Pair sessions are only available in projects");
|
|
235
299
|
var request = args || {};
|
|
@@ -276,6 +340,8 @@ function attachPairFactory(ctx) {
|
|
|
276
340
|
createPair: createPair,
|
|
277
341
|
createPairRecord: createPairRecord,
|
|
278
342
|
createWorkerForDriver: createWorkerForDriver,
|
|
343
|
+
addWorkerForDriver: addWorkerForDriver,
|
|
344
|
+
replaceWorkerForDriver: replaceWorkerForDriver,
|
|
279
345
|
createOwnedPair: createOwnedPair,
|
|
280
346
|
removeOwnedPair: removeOwnedPair,
|
|
281
347
|
preflightRuntime: preflightRuntime,
|
|
@@ -19,31 +19,33 @@ function getToolDefs(handlers, options) {
|
|
|
19
19
|
description: "Delegate one concrete follow-up task to the existing visible Split Worker. This tool is exposed only after the user has configured and accepted an exact pair. A completed turn does not end the Split Worker session: reuse the same Split Worker for follow-up implementation and corrections instead of taking over its work. Detached completions are pushed back automatically. A delegated turn cannot delegate back, so keep orchestration one hop deep.",
|
|
20
20
|
inputSchema: buildShape({
|
|
21
21
|
message: { type: "string", description: "The complete task or question for the partner." },
|
|
22
|
-
wait: { type: "boolean", description: "Wait for the partner's turn to finish. Defaults to true." },
|
|
22
|
+
wait: { type: "boolean", description: "Wait for the partner's turn to finish. Defaults to false; set true for an explicit blocking wait." },
|
|
23
23
|
timeoutSeconds: { type: "number", description: "Maximum wait in seconds, from 1 to 900. Defaults to 300." },
|
|
24
24
|
operationId: { type: "string", description: "Optional stable id for this delegation. Reusing it within the same human turn returns the original operation instead of sending twice." },
|
|
25
25
|
taskId: { type: "string", description: "Optional stable task id for generation and completion correlation." },
|
|
26
|
+
workerId: { type: "number", description: "Optional exact Worker session id; required when multiple Workers are configured." },
|
|
26
27
|
}, ["message"]),
|
|
27
28
|
handler: function (args) { return handlers.send(args || {}); },
|
|
28
29
|
},
|
|
29
30
|
{
|
|
30
31
|
name: "read_partner",
|
|
31
|
-
description: "Read the Split Worker's current status and recent conversation turns, plus the same bounded capacity report partner_status returns. Use
|
|
32
|
+
description: "Read the Split Worker's current status and recent conversation turns, plus the same bounded capacity report partner_status returns. Use only for a requested status, concrete failure or stall diagnosis, or capacity/queue decision; do not poll routinely. Completed results are pushed automatically.",
|
|
32
33
|
inputSchema: buildShape({
|
|
33
34
|
lastTurns: { type: "number", description: "Number of recent user-message-delimited turns, from 0 to 5. Use 0 for status only. Defaults to 1." },
|
|
35
|
+
workerId: { type: "number", description: "Optional exact Worker session id; required when multiple Workers are configured." },
|
|
34
36
|
}),
|
|
35
37
|
handler: function (args) { return handlers.read(args || {}); },
|
|
36
38
|
},
|
|
37
39
|
{
|
|
38
40
|
name: "interrupt_partner",
|
|
39
41
|
description: "Interrupt the Split Worker's current task. Use this when the task is going in the wrong direction, needs to be reprioritized, or must stop before the next instruction. The Split Worker returns any partial result to you for review.",
|
|
40
|
-
inputSchema: buildShape({ reason: { type: "string", description: "Why the Driver is interrupting this task." } }),
|
|
42
|
+
inputSchema: buildShape({ reason: { type: "string", description: "Why the Driver is interrupting this task." }, workerId: { type: "number", description: "Optional exact Worker session id; required when multiple Workers are configured." } }),
|
|
41
43
|
handler: function (args) { return handlers.interrupt(args || {}); },
|
|
42
44
|
},
|
|
43
45
|
{
|
|
44
46
|
name: "close_partner",
|
|
45
47
|
description: "Close the visible Split Worker pane and dissolve the Driver/Split Worker pair while preserving both session histories. If the Split Worker is still running, Clay interrupts it before closing the pair.",
|
|
46
|
-
inputSchema: buildShape({}),
|
|
48
|
+
inputSchema: buildShape({ workerId: { type: "number", description: "Optional exact Worker session id; required when multiple Workers are configured." } }),
|
|
47
49
|
handler: function (args) { return handlers.close(args || {}); },
|
|
48
50
|
},
|
|
49
51
|
];
|
|
@@ -55,13 +57,14 @@ function getToolDefs(handlers, options) {
|
|
|
55
57
|
inputSchema: buildShape({
|
|
56
58
|
message: { type: "string", description: "Bounded correction or context for the current active Worker turn." },
|
|
57
59
|
requestId: { type: "string", description: "Optional stable request id. Reuse it to deduplicate a retried delivery within this human turn." },
|
|
60
|
+
workerId: { type: "number", description: "Optional exact Worker session id; required when multiple Workers are configured." },
|
|
58
61
|
}, ["message"]),
|
|
59
62
|
handler: function (args) { return handlers.message(args || {}); },
|
|
60
63
|
},
|
|
61
64
|
{
|
|
62
65
|
name: "partner_status",
|
|
63
66
|
description: "Bounded capacity and continuity report for your Split Worker, for deciding reuse against replacement: context tokens used and the ratio of its window, current task and activity, vendor/model/effort, history size and idle time, whether it is safe to replace right now, and your own recorded results for earlier Worker generations. Returns no transcript. Prefer this over reading turns when you only need to decide.",
|
|
64
|
-
inputSchema: buildShape({}),
|
|
67
|
+
inputSchema: buildShape({ workerId: { type: "number", description: "Optional exact Worker session id; required when multiple Workers are configured." } }),
|
|
65
68
|
handler: function (args) { return handlers.status(args || {}); },
|
|
66
69
|
},
|
|
67
70
|
{
|
|
@@ -80,6 +83,7 @@ function getToolDefs(handlers, options) {
|
|
|
80
83
|
},
|
|
81
84
|
operationId: { type: "string", description: "Optional stable id for this replacement. Reusing it within the same human turn returns the original operation instead of replacing twice." },
|
|
82
85
|
supersedeProposalId: { type: "string", description: "Exact pending replacement proposal id to supersede with this new configuration." },
|
|
86
|
+
workerId: { type: "number", description: "Optional exact Worker session id; required when multiple Workers are configured." },
|
|
83
87
|
}, ["message", "recommendationRationale"]),
|
|
84
88
|
handler: function (args) { return handlers.replace(args || {}); },
|
|
85
89
|
},
|
|
@@ -90,6 +94,7 @@ function getToolDefs(handlers, options) {
|
|
|
90
94
|
outcome: { enum: EVALUATION_OUTCOMES, description: 'One of "succeeded", "partial", "failed", "abandoned".' },
|
|
91
95
|
note: { type: "string", description: "Optional short reason, up to 400 characters." },
|
|
92
96
|
generation: { type: "number", description: "Optional generation number. Defaults to the current Worker." },
|
|
97
|
+
workerId: { type: "number", description: "Optional exact Worker session id; required when multiple Workers are configured." },
|
|
93
98
|
}, ["outcome"]),
|
|
94
99
|
handler: function (args) { return handlers.evaluate(args || {}); },
|
|
95
100
|
},
|
|
@@ -4,32 +4,42 @@
|
|
|
4
4
|
// the size limit and so the guidance can be reviewed as prose.
|
|
5
5
|
|
|
6
6
|
var WORKER_RUNTIME_SELECTION = [
|
|
7
|
-
"Use a
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"runtime choice; this is a recommendation policy, not a restriction on which model the user may select.",
|
|
7
|
+
"Use a role-based mix by default: keep decomposition, judgment, review, and independent verification with",
|
|
8
|
+
"the Driver, and give the Split Worker concrete, bounded execution tasks with clear acceptance criteria.",
|
|
9
|
+
"Recommend the lowest-capability model and thinking effort that are adequate for the specific task, based on",
|
|
10
|
+
"the requirements, the server-provided capability evidence, the offered runtime catalog, and recorded local",
|
|
11
|
+
"Worker outcomes. Choose vendor, model, and effort separately. Do not mirror the Driver, prefer a vendor by",
|
|
12
|
+
"Explicitly fill the vendor, model, and thinking effort recommendation fields for both creation and replacement;",
|
|
13
|
+
"never rely on card defaults or leave model selection implicit.",
|
|
14
|
+
"brand, or default to the strongest available option. Do not invent availability, prices, rankings, or missing",
|
|
15
|
+
"metadata. If the catalog or capability evidence is incomplete, say what is unknown and choose only from what",
|
|
16
|
+
"is actually offered. Honor an explicit user runtime choice; this is guidance, not a restriction.",
|
|
17
|
+
"Escalate only for a concrete task requirement or an observed failure the current runtime cannot resolve, and",
|
|
18
|
+
"state the evidence. A small review correction is not by itself an escalation reason; send one consolidated",
|
|
19
|
+
"bounded correction to the Worker and verify the result. Repeated same-class failures require reassessing the",
|
|
20
|
+
"task contract, model, and effort with evidence, not endless cheap-model loops or escalation of every correction.",
|
|
21
|
+
"Aim for a role-based workload direction near 30 percent Driver and 70 percent Worker effort when useful, but",
|
|
22
|
+
"never promise that ratio or treat it as a quota, token, or cost guarantee, and never pad work to reach it.",
|
|
24
23
|
].join(" ").replace(/ {2,}/g, " ");
|
|
25
24
|
|
|
26
25
|
var DRIVER_DELEGATION = [
|
|
27
|
-
"Your management objective: protect your own context, keep the Split Worker compact, and put execution where",
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
26
|
+
"Your management objective: protect your own context, keep the Split Worker compact, and put substantive execution where it runs best.",
|
|
27
|
+
"Delegate implementation-heavy work rather than doing it here. Treat work spanning multiple modules, crossing",
|
|
28
|
+
"client/server/data boundaries, requiring independent investigation, testing, or self-correction as implementation-heavy.",
|
|
29
|
+
"Your own capability is not a reason to retain that execution: preserve the Driver for decomposition,",
|
|
30
|
+
"runtime selection, judgment, review, and integration unless the user explicitly asks you to work directly or the",
|
|
31
|
+
"work must remain sequential in this session.",
|
|
32
|
+
"Give each Worker one bounded contract: goal and scope, constraints and non-goals, acceptance commands and the",
|
|
33
|
+
"expected behaviors, and the required handoff. Delegate substantive investigation, implementation, testing, and",
|
|
34
|
+
"self-correction. The Worker must finish its allowed scope, inspect its diff against the criteria, run meaningful",
|
|
35
|
+
"production-path tests, and correct failures before reporting. It must not hand back an unchecked draft. Require",
|
|
36
|
+
"a bounded self-correction attempt; when progress is not possible, require a precise blocker and evidence.",
|
|
37
|
+
"Review once per coherent completed milestone with risk-focused source review and independent verification. Do not",
|
|
38
|
+
"rubber-stamp or sacrifice quality for an efficiency direction. Consolidate review feedback into one correction",
|
|
39
|
+
"task rather than sending overlapping live and queued work.",
|
|
40
|
+
"When discussing measured efficiency, distinguish requests, input tokens, output tokens, cached tokens, and cost.",
|
|
41
|
+
"Unknown usage is not zero; the 30/70 direction is role-based and aspirational, not a guaranteed token or cost",
|
|
42
|
+
"ratio or quota.",
|
|
33
43
|
].join(" ").replace(/ {2,}/g, " ");
|
|
34
44
|
|
|
35
45
|
var DRIVER_CORE = [
|
|
@@ -76,7 +86,13 @@ var DRIVER_CORE = [
|
|
|
76
86
|
"ambiguous and a visible pair exists, prefer the visible Split Worker. If review or user feedback requires",
|
|
77
87
|
"corrections to the Split Worker's implementation, delegate a follow-up turn to that Split Worker instead of",
|
|
78
88
|
"editing its files yourself. If a non-waiting delegation finishes later, Clay pushes the result back and",
|
|
79
|
-
"resumes you automatically.
|
|
89
|
+
"resumes you automatically. send_to_partner returns immediately by default; set wait to true only when you",
|
|
90
|
+
"explicitly need the final result in the same tool call. Do useful independent work after a non-waiting",
|
|
91
|
+
"delegation, then yield for Clay's automatic completion callback; do not poll read_partner or partner_status,",
|
|
92
|
+
"or sleep while waiting. Use those reads only for a user-requested status, concrete failure or stall diagnosis,",
|
|
93
|
+
"capacity/reuse decision, or exact queue inspection before mutation. Do not send the same correction as both",
|
|
94
|
+
"a live message and a queued task; consolidate review feedback into one bounded follow-up. You also decide",
|
|
95
|
+
"the Split Worker's tool-permission requests when they arise;",
|
|
80
96
|
"approve only what falls inside the task the user authorized. Integrate and verify the final outcome. Do not",
|
|
81
97
|
"search the project for implementations of these tools, and do not delegate work that must be performed",
|
|
82
98
|
"sequentially in this same session.",
|
|
@@ -85,7 +101,7 @@ var DRIVER_CORE = [
|
|
|
85
101
|
var DRIVER = DRIVER_CORE + " " + DRIVER_DELEGATION;
|
|
86
102
|
|
|
87
103
|
function worker(taskId) {
|
|
88
|
-
return "You are the configured Split Worker. For task " + (taskId || "unknown") + ",
|
|
104
|
+
return "You are the configured Split Worker. For task " + (taskId || "unknown") + ", finish only the allowed bounded scope. Inspect your diff against the stated acceptance criteria, run meaningful production-path tests, and correct your own failures within a bounded self-correction attempt before reporting. Do not hand back an unchecked draft or hide a failure; if progress is blocked, report the precise blocker and evidence. Call report_partner_outcome before finishing when the tool is available. Report only what you observed: changed files and behavior, commands with actual exit results, known limits or unverified work, running processes, and next action. Never claim Driver verification.";
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
var UNPAIRED = [
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
var splitRoles = require("./session-split-group-roles");
|
|
2
|
+
var multiWorkerFeature = require("./multi-worker-feature");
|
|
3
|
+
|
|
4
|
+
function resolveGroupTarget(sm, group, caller, args, options) {
|
|
5
|
+
if (!caller || sm.sessions.get(caller.localId) !== caller) throw new Error("this session is no longer live; the partner tools are bound to an exact session");
|
|
6
|
+
var requestedWorkerId = args && args.workerId;
|
|
7
|
+
var normalized = splitRoles.normalizePair(group && group.pair, group && group.members);
|
|
8
|
+
if (!normalized.ok) throw new Error(normalized.error);
|
|
9
|
+
var workerId;
|
|
10
|
+
if (normalized.kind === "adhoc") {
|
|
11
|
+
workerId = group.members[0] === caller.localId ? group.members[1] : group.members[0];
|
|
12
|
+
if (requestedWorkerId != null && requestedWorkerId !== workerId) throw new Error("workerId must identify the exact split partner");
|
|
13
|
+
} else {
|
|
14
|
+
if (normalized.driverId !== caller.localId) throw new Error("only the configured Driver can direct this pair");
|
|
15
|
+
var target = splitRoles.resolveWorkerTarget(normalized, requestedWorkerId);
|
|
16
|
+
if (!target.ok) throw new Error(target.error);
|
|
17
|
+
workerId = target.workerId;
|
|
18
|
+
if (normalized.kind === "versioned" && (!options || options.readOnly !== true) &&
|
|
19
|
+
(!options || !multiWorkerFeature.isEnabled(options.multiWorkerFeature))) {
|
|
20
|
+
throw new Error("multi-Worker mutations remain gated until lifecycle, result, and Stop routing are migrated");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
var worker = sm.sessions.get(workerId);
|
|
24
|
+
if (!worker || sm.sessions.get(worker.localId) !== worker) throw new Error("split partner session was not found");
|
|
25
|
+
if ((caller.ownerId || null) !== (worker.ownerId || null)) throw new Error("split partner access denied");
|
|
26
|
+
if (group.members.indexOf(caller.localId) === -1 || group.members.indexOf(worker.localId) === -1) {
|
|
27
|
+
throw new Error("the exact caller and Worker must both belong to this split group");
|
|
28
|
+
}
|
|
29
|
+
return { group: group, partner: worker, role: normalized, workerId: workerId };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rolesFor(sm, store, session) {
|
|
33
|
+
if (!session || sm.sessions.get(session.localId) !== session) return null;
|
|
34
|
+
var group = store.groupForMember(session.localId);
|
|
35
|
+
var roles = group && group.pair ? splitRoles.resolveLiveRoles(sm, group) : null;
|
|
36
|
+
if (!roles) return null;
|
|
37
|
+
roles.worker = roles.workers.length === 1 ? roles.workers[0] :
|
|
38
|
+
roles.workers.find(function (worker) { return worker === session; }) || null;
|
|
39
|
+
return roles;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { resolveGroupTarget: resolveGroupTarget, rolesFor: rolesFor };
|
|
@@ -10,6 +10,7 @@ var MAX_CREATIONS_PER_TURN = 2;
|
|
|
10
10
|
var MAX_REPLACEMENTS_PER_TURN = 1;
|
|
11
11
|
var MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN = 2;
|
|
12
12
|
var MAX_OPERATION_ID_CHARS = 120;
|
|
13
|
+
var splitRoles = require("./session-split-group-roles");
|
|
13
14
|
|
|
14
15
|
function attachPairTurnControl(ctx) {
|
|
15
16
|
var sm = ctx.sm;
|
|
@@ -22,6 +23,7 @@ function attachPairTurnControl(ctx) {
|
|
|
22
23
|
humanStopped: false,
|
|
23
24
|
stoppedAt: null,
|
|
24
25
|
stoppedWorkerId: null,
|
|
26
|
+
stoppedWorkerIds: [],
|
|
25
27
|
creations: 0,
|
|
26
28
|
replacements: 0,
|
|
27
29
|
failedReplacementAttempts: 0,
|
|
@@ -42,11 +44,11 @@ function attachPairTurnControl(ctx) {
|
|
|
42
44
|
if (!liveSession(session)) return null;
|
|
43
45
|
var group = store.groupForMember(session.localId);
|
|
44
46
|
if (!group || !group.pair) return null;
|
|
45
|
-
var
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return
|
|
47
|
+
var roles = splitRoles.resolveLiveRoles(sm, group);
|
|
48
|
+
if (!roles) return null;
|
|
49
|
+
roles.worker = roles.workers.length === 1 ? roles.workers[0] :
|
|
50
|
+
roles.workers.find(function (worker) { return worker === session; }) || null;
|
|
51
|
+
return roles;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
function beginHumanTurn(session) {
|
|
@@ -59,6 +61,7 @@ function attachPairTurnControl(ctx) {
|
|
|
59
61
|
state.humanStopped = false;
|
|
60
62
|
state.stoppedAt = null;
|
|
61
63
|
state.stoppedWorkerId = null;
|
|
64
|
+
state.stoppedWorkerIds = [];
|
|
62
65
|
state.creations = 0;
|
|
63
66
|
state.replacements = 0;
|
|
64
67
|
state.failedReplacementAttempts = 0;
|
|
@@ -72,7 +75,8 @@ function attachPairTurnControl(ctx) {
|
|
|
72
75
|
var state = stateFor(roles.driver);
|
|
73
76
|
state.humanStopped = true;
|
|
74
77
|
state.stoppedAt = Date.now();
|
|
75
|
-
state.stoppedWorkerId = roles.worker.localId;
|
|
78
|
+
state.stoppedWorkerId = roles.worker ? roles.worker.localId : null;
|
|
79
|
+
state.stoppedWorkerIds = roles.workers.map(function (worker) { return worker.localId; });
|
|
76
80
|
return roles;
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -147,6 +151,7 @@ function attachPairTurnControl(ctx) {
|
|
|
147
151
|
humanStopped: state.humanStopped,
|
|
148
152
|
stoppedAt: state.stoppedAt,
|
|
149
153
|
stoppedWorkerId: state.stoppedWorkerId,
|
|
154
|
+
stoppedWorkerIds: state.stoppedWorkerIds.slice(),
|
|
150
155
|
turnSerial: state.serial,
|
|
151
156
|
creationsThisTurn: state.creations,
|
|
152
157
|
replacementsThisTurn: state.replacements,
|
|
@@ -2,6 +2,8 @@ function value(input) {
|
|
|
2
2
|
return typeof input === "string" && input ? input : null;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
var splitRoles = require("./session-split-group-roles");
|
|
6
|
+
|
|
5
7
|
function pairFor(session, previousCli, previousOrigin) {
|
|
6
8
|
return {
|
|
7
9
|
cli: value(session && session.cliSessionId) || value(previousCli),
|
|
@@ -9,16 +11,87 @@ function pairFor(session, previousCli, previousOrigin) {
|
|
|
9
11
|
};
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
function versionedAnchor(session) {
|
|
15
|
+
var cli = value(session && session.cliSessionId);
|
|
16
|
+
var origin = value(session && session.sessionOriginId);
|
|
17
|
+
if (!cli && !origin) return null;
|
|
18
|
+
return { cli: cli, origin: origin };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function versionedPairAnchors(group, sessions) {
|
|
22
|
+
var normalized = splitRoles.normalizePair(group && group.pair, group && group.members);
|
|
23
|
+
if (!normalized.ok || normalized.kind !== "versioned") return null;
|
|
24
|
+
var driver = versionedAnchor(sessions.get(group.pair.driverId));
|
|
25
|
+
if (!driver) return null;
|
|
26
|
+
var workers = [];
|
|
27
|
+
for (var i = 0; i < normalized.workerIds.length; i++) {
|
|
28
|
+
var worker = versionedAnchor(sessions.get(normalized.workerIds[i]));
|
|
29
|
+
if (!worker) return null;
|
|
30
|
+
workers.push(worker);
|
|
31
|
+
}
|
|
32
|
+
return { version: 2, driver: driver, workers: workers };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function versionedMemberAnchors(group, sessions) {
|
|
36
|
+
var normalized = splitRoles.normalizePair(group && group.pair, group && group.members);
|
|
37
|
+
if (!normalized.ok || normalized.kind !== "versioned") return null;
|
|
38
|
+
var members = [];
|
|
39
|
+
for (var i = 0; i < group.members.length; i++) {
|
|
40
|
+
var anchor = versionedAnchor(sessions.get(group.members[i]));
|
|
41
|
+
if (!anchor) return null;
|
|
42
|
+
members.push(anchor);
|
|
43
|
+
}
|
|
44
|
+
return { version: 2, members: members };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveVersionedPair(sessions, anchors) {
|
|
48
|
+
if (!anchors || anchors.version !== 2 || !anchors.driver || !Array.isArray(anchors.workers) ||
|
|
49
|
+
(anchors.workers.length !== 1 && anchors.workers.length !== 2)) return null;
|
|
50
|
+
var driver = findSession(sessions, anchors.driver.cli, anchors.driver.origin);
|
|
51
|
+
if (!driver) return null;
|
|
52
|
+
var workers = [];
|
|
53
|
+
for (var i = 0; i < anchors.workers.length; i++) {
|
|
54
|
+
var workerAnchor = anchors.workers[i];
|
|
55
|
+
if (!workerAnchor || (!value(workerAnchor.cli) && !value(workerAnchor.origin))) return null;
|
|
56
|
+
var worker = workerAnchor && findSession(sessions, workerAnchor.cli, workerAnchor.origin);
|
|
57
|
+
if (!worker || workers.indexOf(worker) !== -1 || worker === driver) return null;
|
|
58
|
+
workers.push(worker);
|
|
59
|
+
}
|
|
60
|
+
return { driver: driver, workers: workers };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveVersionedMembers(sessions, anchors) {
|
|
64
|
+
if (!anchors || anchors.version !== 2 || !Array.isArray(anchors.members) ||
|
|
65
|
+
(anchors.members.length !== 2 && anchors.members.length !== 3)) return null;
|
|
66
|
+
var resolved = [];
|
|
67
|
+
for (var i = 0; i < anchors.members.length; i++) {
|
|
68
|
+
var anchor = anchors.members[i];
|
|
69
|
+
if (!anchor || (!value(anchor.cli) && !value(anchor.origin))) return null;
|
|
70
|
+
var session = anchor && findSession(sessions, anchor.cli, anchor.origin);
|
|
71
|
+
if (!session || resolved.indexOf(session) !== -1) return null;
|
|
72
|
+
resolved.push(session);
|
|
73
|
+
}
|
|
74
|
+
return resolved;
|
|
75
|
+
}
|
|
76
|
+
|
|
12
77
|
function memberAnchors(group, sessions) {
|
|
13
|
-
var
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
var
|
|
17
|
-
|
|
78
|
+
var normalized = splitRoles.normalizePair(group && group.pair, group && group.members);
|
|
79
|
+
if (!normalized.ok) return null;
|
|
80
|
+
if (normalized.kind === "versioned") return versionedMemberAnchors(group, sessions);
|
|
81
|
+
var previousCli = Array.isArray(group.memberCliIds) ? group.memberCliIds : [];
|
|
82
|
+
var previousOrigin = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [];
|
|
83
|
+
var cli = [], origin = [];
|
|
84
|
+
for (var i = 0; i < group.members.length; i++) {
|
|
85
|
+
var anchor = pairFor(sessions.get(group.members[i]), previousCli[i], previousOrigin[i]);
|
|
86
|
+
cli.push(anchor.cli); origin.push(anchor.origin);
|
|
87
|
+
}
|
|
88
|
+
return { cli: cli, origin: origin };
|
|
18
89
|
}
|
|
19
90
|
|
|
20
91
|
function pairAnchors(group, sessions) {
|
|
21
|
-
|
|
92
|
+
var normalized = splitRoles.normalizePair(group && group.pair, group && group.members);
|
|
93
|
+
if (!normalized.ok || normalized.kind === "adhoc") return null;
|
|
94
|
+
if (normalized.kind === "versioned") return versionedPairAnchors(group, sessions);
|
|
22
95
|
var previousCli = Array.isArray(group.pairCliIds) ? group.pairCliIds : [null, null];
|
|
23
96
|
var previousOrigin = Array.isArray(group.pairOriginIds) ? group.pairOriginIds : [null, null];
|
|
24
97
|
var driver = pairFor(sessions.get(group.pair.driverId), previousCli[0], previousOrigin[0]);
|
|
@@ -41,4 +114,13 @@ function findSession(sessions, cliId, originId) {
|
|
|
41
114
|
return ambiguous ? null : found;
|
|
42
115
|
}
|
|
43
116
|
|
|
44
|
-
module.exports = {
|
|
117
|
+
module.exports = {
|
|
118
|
+
findSession: findSession,
|
|
119
|
+
memberAnchors: memberAnchors,
|
|
120
|
+
pairAnchors: pairAnchors,
|
|
121
|
+
resolveVersionedMembers: resolveVersionedMembers,
|
|
122
|
+
resolveVersionedPair: resolveVersionedPair,
|
|
123
|
+
versionedAnchor: versionedAnchor,
|
|
124
|
+
versionedMemberAnchors: versionedMemberAnchors,
|
|
125
|
+
versionedPairAnchors: versionedPairAnchors,
|
|
126
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
var fs = require("fs");
|
|
2
|
+
var path = require("path");
|
|
3
|
+
var anchors = require("./session-split-group-anchors");
|
|
4
|
+
var roles = require("./session-split-group-roles");
|
|
5
|
+
|
|
6
|
+
var ANCHOR_KEYS = ["memberCliIds", "memberOriginIds", "pairCliIds", "pairOriginIds", "memberAnchors", "pairAnchors"];
|
|
7
|
+
|
|
8
|
+
function attachPersistence(opts) {
|
|
9
|
+
var sessions = opts.sessions;
|
|
10
|
+
var groupsFile = path.join(opts.sessionsDir, "split-groups.json");
|
|
11
|
+
var preserved = [];
|
|
12
|
+
|
|
13
|
+
function anchorPair(group) {
|
|
14
|
+
if (!group.pair) return;
|
|
15
|
+
var pair = anchors.pairAnchors(group, sessions);
|
|
16
|
+
if (!pair) return;
|
|
17
|
+
if (pair.version === 2) {
|
|
18
|
+
group.pairAnchors = pair;
|
|
19
|
+
delete group.pairCliIds; delete group.pairOriginIds;
|
|
20
|
+
} else {
|
|
21
|
+
group.pairCliIds = pair.cli; group.pairOriginIds = pair.origin;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function anchoredClone(group) {
|
|
26
|
+
var clone = Object.assign({}, group, { members: group.members.slice(),
|
|
27
|
+
pair: group.pair ? Object.assign({}, group.pair) : undefined });
|
|
28
|
+
if (clone.pair && Array.isArray(clone.pair.workerIds)) clone.pair.workerIds = clone.pair.workerIds.slice();
|
|
29
|
+
var member = anchors.memberAnchors(clone, sessions);
|
|
30
|
+
if (!member) throw new Error("Split group does not have durable session anchors");
|
|
31
|
+
if (member.version === 2) {
|
|
32
|
+
clone.memberAnchors = member;
|
|
33
|
+
delete clone.memberCliIds; delete clone.memberOriginIds;
|
|
34
|
+
} else {
|
|
35
|
+
clone.memberCliIds = member.cli; clone.memberOriginIds = member.origin;
|
|
36
|
+
}
|
|
37
|
+
anchorPair(clone);
|
|
38
|
+
return clone;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function save(groups) {
|
|
42
|
+
var saved = groups.map(anchoredClone);
|
|
43
|
+
var serialized = JSON.stringify(saved.concat(preserved), null, 2) + "\n";
|
|
44
|
+
if (typeof opts.persistGroups === "function") {
|
|
45
|
+
if (opts.persistGroups(groupsFile, serialized) === false) throw new Error("Split group persistence returned false");
|
|
46
|
+
} else {
|
|
47
|
+
var tmp = groupsFile + ".tmp." + process.pid;
|
|
48
|
+
fs.mkdirSync(opts.sessionsDir, { recursive: true });
|
|
49
|
+
fs.writeFileSync(tmp, serialized);
|
|
50
|
+
if (process.platform !== "win32") try { fs.chmodSync(tmp, 0o600); } catch (e) {}
|
|
51
|
+
fs.renameSync(tmp, groupsFile);
|
|
52
|
+
}
|
|
53
|
+
for (var i = 0; i < groups.length; i++) {
|
|
54
|
+
(function (live, stored) {
|
|
55
|
+
ANCHOR_KEYS.forEach(function (key) {
|
|
56
|
+
if (Object.prototype.hasOwnProperty.call(stored, key)) live[key] = stored[key];
|
|
57
|
+
else delete live[key];
|
|
58
|
+
});
|
|
59
|
+
})(groups[i], saved[i]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveVersioned(group, normalized) {
|
|
64
|
+
if (typeof group.id !== "string" || !group.id) return false;
|
|
65
|
+
var members = anchors.resolveVersionedMembers(sessions, group.memberAnchors);
|
|
66
|
+
var pair = anchors.resolveVersionedPair(sessions, group.pairAnchors);
|
|
67
|
+
if (!members || !pair) return false;
|
|
68
|
+
var memberIds = members.map(function (session) { return session.localId; });
|
|
69
|
+
var workerIds = pair.workers.map(function (session) { return session.localId; });
|
|
70
|
+
if (memberIds.length !== group.members.length || workerIds.length !== normalized.workerIds.length) return false;
|
|
71
|
+
var nextPair = { version: 2, driverId: pair.driver.localId, workerIds: workerIds };
|
|
72
|
+
var nextRoles = roles.normalizePair(nextPair, memberIds);
|
|
73
|
+
if (!nextRoles.ok || nextRoles.kind !== "versioned") return false;
|
|
74
|
+
var changed = group.members.join(",") !== memberIds.join(",") ||
|
|
75
|
+
group.pair.driverId !== pair.driver.localId || group.pair.workerIds.join(",") !== workerIds.join(",");
|
|
76
|
+
group.members = memberIds;
|
|
77
|
+
group.pair = nextPair;
|
|
78
|
+
return { changed: changed };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveLegacy(group) {
|
|
82
|
+
var changed = false;
|
|
83
|
+
if (Array.isArray(group.memberCliIds) || Array.isArray(group.memberOriginIds)) {
|
|
84
|
+
var cli = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
|
|
85
|
+
var origin = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
|
|
86
|
+
var left = anchors.findSession(sessions, cli[0], origin[0]);
|
|
87
|
+
var right = anchors.findSession(sessions, cli[1], origin[1]);
|
|
88
|
+
if (!left || !right || left === right) return false;
|
|
89
|
+
if (group.members[0] !== left.localId || group.members[1] !== right.localId) {
|
|
90
|
+
group.members = [left.localId, right.localId]; changed = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (group.pair && (Array.isArray(group.pairCliIds) || Array.isArray(group.pairOriginIds))) {
|
|
94
|
+
var pairCli = Array.isArray(group.pairCliIds) ? group.pairCliIds : [null, null];
|
|
95
|
+
var pairOrigin = Array.isArray(group.pairOriginIds) ? group.pairOriginIds : [null, null];
|
|
96
|
+
var driver = anchors.findSession(sessions, pairCli[0], pairOrigin[0]);
|
|
97
|
+
var worker = anchors.findSession(sessions, pairCli[1], pairOrigin[1]);
|
|
98
|
+
if (driver && worker && (group.pair.driverId !== driver.localId || group.pair.workerId !== worker.localId)) {
|
|
99
|
+
group.pair.driverId = driver.localId; group.pair.workerId = worker.localId; changed = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return roles.validateStoredGroup(group, sessions) ? { changed: changed } : false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function load() {
|
|
106
|
+
var loaded = [], rewrite = false, claimed = {};
|
|
107
|
+
preserved = [];
|
|
108
|
+
try { loaded = JSON.parse(fs.readFileSync(groupsFile, "utf8")); if (!Array.isArray(loaded)) loaded = []; }
|
|
109
|
+
catch (error) { if (error.code !== "ENOENT") rewrite = true; }
|
|
110
|
+
var groups = loaded.filter(function (group) {
|
|
111
|
+
var normalized = group && roles.normalizePair(group.pair, group.members);
|
|
112
|
+
if (!normalized || !normalized.ok) {
|
|
113
|
+
if (group && roles.isUnsupportedPair(group.pair)) preserved.push(group);
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
var versioned = !!(normalized && normalized.ok && normalized.kind === "versioned");
|
|
117
|
+
if (versioned && !opts.versionedEnabled) { preserved.push(group); return false; }
|
|
118
|
+
var resolved = versioned ? resolveVersioned(group, normalized) : resolveLegacy(group);
|
|
119
|
+
if (!resolved) {
|
|
120
|
+
if (versioned || group && roles.isUnsupportedPair(group.pair)) preserved.push(group);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (resolved.changed) rewrite = true;
|
|
124
|
+
for (var i = 0; i < group.members.length; i++) if (claimed[group.members[i]]) return false;
|
|
125
|
+
for (var j = 0; j < group.members.length; j++) claimed[group.members[j]] = true;
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
if (groups.length + preserved.length !== loaded.length) rewrite = true;
|
|
129
|
+
if (rewrite) save(groups);
|
|
130
|
+
return groups;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { load: load, save: save };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { attachPersistence: attachPersistence };
|