clay-server 4.2.0 → 4.3.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/lib/driver-continuation-access.js +54 -0
  2. package/lib/driver-continuation-lease.js +52 -0
  3. package/lib/driver-continuation-pair-transfer.js +86 -0
  4. package/lib/driver-continuation-pair.js +86 -0
  5. package/lib/driver-continuation-record.js +40 -0
  6. package/lib/driver-continuation-startup.js +68 -0
  7. package/lib/driver-continuation-transaction.js +61 -0
  8. package/lib/driver-continuation-trigger.js +110 -0
  9. package/lib/multi-worker-feature.js +11 -0
  10. package/lib/project-connection.js +3 -1
  11. package/lib/project-driver-continuation.js +499 -0
  12. package/lib/project-message-delivery.js +20 -0
  13. package/lib/project-pair-autonomous-stop.js +14 -8
  14. package/lib/project-pair-close-transaction.js +59 -0
  15. package/lib/project-pair-global-stop.js +18 -0
  16. package/lib/project-pair-lifecycle-status.js +34 -0
  17. package/lib/project-pair-lifecycle.js +117 -135
  18. package/lib/project-pair-message.js +1 -1
  19. package/lib/project-pair-owned-stop.js +7 -4
  20. package/lib/project-pair-replacement-state.js +5 -1
  21. package/lib/project-pair-result-capture.js +116 -0
  22. package/lib/project-pair-result-delivery.js +140 -0
  23. package/lib/project-pair-result-outbox.js +400 -0
  24. package/lib/project-pair-result-pipeline.js +16 -0
  25. package/lib/project-pair-result-recovery.js +229 -0
  26. package/lib/project-pair-structural-close.js +20 -0
  27. package/lib/project-pair-task-control.js +45 -18
  28. package/lib/project-pair-worker-close.js +68 -0
  29. package/lib/project-session-handoff.js +48 -55
  30. package/lib/project-session-pair-prompt.js +26 -0
  31. package/lib/project-session-pair.js +87 -106
  32. package/lib/project-sessions.js +4 -0
  33. package/lib/project-user-message.js +21 -0
  34. package/lib/project-worker-creation-proposal.js +176 -0
  35. package/lib/project-worker-permission.js +7 -8
  36. package/lib/project-worker-proposal-runner.js +45 -0
  37. package/lib/project-worker-proposal.js +79 -115
  38. package/lib/project.js +87 -3
  39. package/lib/public/app.js +1 -0
  40. package/lib/public/css/driver-continuation.css +56 -0
  41. package/lib/public/css/messages.css +12 -0
  42. package/lib/public/css/mobile-nav.css +29 -14
  43. package/lib/public/css/overlays.css +54 -25
  44. package/lib/public/css/pane.css +66 -12
  45. package/lib/public/css/sidebar.css +62 -7
  46. package/lib/public/index.html +3 -3
  47. package/lib/public/modules/app-connection.js +165 -74
  48. package/lib/public/modules/app-messages.js +31 -3
  49. package/lib/public/modules/driver-continuation-state.js +76 -0
  50. package/lib/public/modules/driver-continuation.js +260 -0
  51. package/lib/public/modules/message-delivery-ui.js +63 -0
  52. package/lib/public/modules/message-delivery.js +163 -6
  53. package/lib/public/modules/pair-result-status.js +87 -0
  54. package/lib/public/modules/permission-control.js +10 -5
  55. package/lib/public/modules/project-activation.js +10 -7
  56. package/lib/public/modules/session-creation-cta.js +46 -0
  57. package/lib/public/modules/sidebar-mobile.js +42 -30
  58. package/lib/public/modules/sidebar-sessions.js +77 -52
  59. package/lib/public/modules/split-group-helpers.js +74 -2
  60. package/lib/public/modules/split-pair-ui.js +28 -6
  61. package/lib/public/modules/split-pane-reconciler.js +28 -0
  62. package/lib/public/modules/split-pane-renderer.js +107 -0
  63. package/lib/public/modules/split-view.js +58 -188
  64. package/lib/public/modules/websocket-lifecycle.js +178 -0
  65. package/lib/public/modules/websocket-watchdog.js +126 -0
  66. package/lib/public/modules/worker-pane-lock.js +13 -3
  67. package/lib/public/style.css +1 -0
  68. package/lib/sdk-bridge.js +77 -0
  69. package/lib/server.js +1 -0
  70. package/lib/session-handoff-discovery.js +443 -0
  71. package/lib/session-handoff-mcp-server.js +32 -4
  72. package/lib/session-pair-factory.js +66 -0
  73. package/lib/session-pair-mcp-server.js +10 -5
  74. package/lib/session-pair-prompts.js +46 -25
  75. package/lib/session-pair-target.js +42 -0
  76. package/lib/session-pair-turn-control.js +30 -9
  77. package/lib/session-split-group-anchors.js +126 -0
  78. package/lib/session-split-group-persistence.js +136 -0
  79. package/lib/session-split-group-roles.js +164 -0
  80. package/lib/session-split-group-v2-store.js +146 -0
  81. package/lib/session-split-groups.js +293 -133
  82. package/lib/sessions.js +89 -22
  83. package/lib/ws-schema.js +8 -0
  84. package/package.json +1 -1
@@ -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 high-low model mix by default: keep decomposition, judgment, review, and independent verification",
8
- "with the Driver. Give the Split Worker concrete, bounded execution tasks with clear acceptance criteria.",
9
- "For routine bounded implementation, start with a lightweight Worker at low effort. When the installed",
10
- "catalog offers it, codex / gpt-5.6-luna / low is the starting recommendation; otherwise choose a comparable",
11
- "lightweight option from the offered catalog. Do not routinely recommend gpt-5.6-sol at high effort.",
12
- "Do not mirror the Driver's model or default to the",
13
- "strongest available model merely because it is available. Explicitly fill the vendor, model, and thinking",
14
- "effort recommendation fields for both creation and replacement; never leave model selection to card defaults.",
15
- "Select model capability and thinking effort separately. Start with the lowest effort adequate for the task,",
16
- "not high effort by habit. Choose from the installed, offered catalog; do not invent model availability or prices.",
17
- "Explain why the lighter choice can handle the task. Escalate only for a concrete requirement beyond its",
18
- "capabilities or observed failures it cannot adequately resolve, and state that reason. Small corrections",
19
- "found during Driver review are not by themselves a reason to escalate. Send those corrections back to the",
20
- "Worker and verify the final result. Optimize for time to a completed, verified outcome, not first-draft speed.",
21
- "Use recorded Worker outcomes when available,",
22
- "and reconsider a lighter model for the next bounded task after an escalation. Honor the user's explicit",
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
- "it runs best. Delegate implementation-heavy work rather than doing it here. Treat work spanning multiple modules,",
29
- "crossing client/server/data boundaries, requiring a migration, or containing independent investigation and",
30
- "execution as implementation-heavy. Your own capability is not a reason to retain that execution: preserve",
31
- "the Driver for decomposition, runtime selection, judgment, review, and integration unless the user explicitly",
32
- "asks you to work directly or the work must remain sequential in this session.",
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 = [
@@ -55,6 +65,11 @@ var DRIVER_CORE = [
55
65
  "interrupt true, which stops it first only after acceptance. A human Stop",
56
66
  "is authoritative: do not retry, send more work, or replace the Worker in the same turn. Clay blocks those",
57
67
  "actions until the human sends a new Driver message. Use close_partner when they ask to close the pane.",
68
+ "If replacement fails before a new Worker is created, retry only when the transaction explicitly confirms a safe",
69
+ "rollback; still respect the failed-attempt cap and the human Stop barrier. If a new Worker was created but its task or transport",
70
+ "failed, keep that Worker and use send_to_partner for a follow-up; do not replace it again merely because execution",
71
+ "failed. True replacement quota exhaustion remains bounded and honest, and a human Stop always requires a new",
72
+ "human message.",
58
73
  "A Worker outcome envelope is Worker-reported evidence, not Driver verification. Preserve partial results and",
59
74
  "correlate completions to their task and generation; never attach a late old-generation result to a new Worker.",
60
75
  "",
@@ -71,7 +86,13 @@ var DRIVER_CORE = [
71
86
  "ambiguous and a visible pair exists, prefer the visible Split Worker. If review or user feedback requires",
72
87
  "corrections to the Split Worker's implementation, delegate a follow-up turn to that Split Worker instead of",
73
88
  "editing its files yourself. If a non-waiting delegation finishes later, Clay pushes the result back and",
74
- "resumes you automatically. You also decide the Split Worker's tool-permission requests when they arise;",
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;",
75
96
  "approve only what falls inside the task the user authorized. Integrate and verify the final outcome. Do not",
76
97
  "search the project for implementations of these tools, and do not delegate work that must be performed",
77
98
  "sequentially in this same session.",
@@ -80,7 +101,7 @@ var DRIVER_CORE = [
80
101
  var DRIVER = DRIVER_CORE + " " + DRIVER_DELEGATION;
81
102
 
82
103
  function worker(taskId) {
83
- return "You are the configured Split Worker. For task " + (taskId || "unknown") + ", call report_partner_outcome before finishing when the tool is available. Report only what you observed: changed files, verification commands/results, unverified work, running processes, and next action. Never claim Driver verification.";
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.";
84
105
  }
85
106
 
86
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 };
@@ -8,7 +8,9 @@
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;
13
+ var splitRoles = require("./session-split-group-roles");
12
14
 
13
15
  function attachPairTurnControl(ctx) {
14
16
  var sm = ctx.sm;
@@ -21,11 +23,16 @@ function attachPairTurnControl(ctx) {
21
23
  humanStopped: false,
22
24
  stoppedAt: null,
23
25
  stoppedWorkerId: null,
26
+ stoppedWorkerIds: [],
24
27
  creations: 0,
25
28
  replacements: 0,
29
+ failedReplacementAttempts: 0,
26
30
  operations: Object.create(null),
27
31
  };
28
32
  }
33
+ if (!Number.isFinite(driver._pairTurnControl.failedReplacementAttempts)) {
34
+ driver._pairTurnControl.failedReplacementAttempts = 0;
35
+ }
29
36
  return driver._pairTurnControl;
30
37
  }
31
38
 
@@ -37,11 +44,11 @@ function attachPairTurnControl(ctx) {
37
44
  if (!liveSession(session)) return null;
38
45
  var group = store.groupForMember(session.localId);
39
46
  if (!group || !group.pair) return null;
40
- var driver = sm.sessions.get(group.pair.driverId);
41
- var worker = sm.sessions.get(group.pair.workerId);
42
- if (!liveSession(driver) || !liveSession(worker)) return null;
43
- if ((driver.ownerId || null) !== (worker.ownerId || null)) return null;
44
- return { group: group, driver: driver, worker: worker };
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;
45
52
  }
46
53
 
47
54
  function beginHumanTurn(session) {
@@ -54,8 +61,10 @@ function attachPairTurnControl(ctx) {
54
61
  state.humanStopped = false;
55
62
  state.stoppedAt = null;
56
63
  state.stoppedWorkerId = null;
64
+ state.stoppedWorkerIds = [];
57
65
  state.creations = 0;
58
66
  state.replacements = 0;
67
+ state.failedReplacementAttempts = 0;
59
68
  state.operations = Object.create(null);
60
69
  return true;
61
70
  }
@@ -66,7 +75,8 @@ function attachPairTurnControl(ctx) {
66
75
  var state = stateFor(roles.driver);
67
76
  state.humanStopped = true;
68
77
  state.stoppedAt = Date.now();
69
- 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; });
70
80
  return roles;
71
81
  }
72
82
 
@@ -88,20 +98,28 @@ function attachPairTurnControl(ctx) {
88
98
  if (kind === "replace" && state.replacements >= MAX_REPLACEMENTS_PER_TURN) {
89
99
  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
100
  }
101
+ if (kind === "replace" && state.failedReplacementAttempts >= MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN) {
102
+ 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");
103
+ }
91
104
  if (state.creations >= MAX_CREATIONS_PER_TURN) {
92
105
  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
106
  }
94
107
  state.creations += 1;
95
108
  if (kind === "replace") state.replacements += 1;
96
- return { driver: driver, kind: kind, active: true };
109
+ return { driver: driver, kind: kind, turnSerial: state.serial, active: true };
97
110
  }
98
111
 
99
112
  function releaseCreation(ticket) {
100
- if (!ticket || !ticket.active || !liveSession(ticket.driver)) return;
113
+ if (!ticket || !ticket.active) return;
101
114
  ticket.active = false;
115
+ if (!liveSession(ticket.driver)) return;
102
116
  var state = stateFor(ticket.driver);
117
+ if (state.serial !== ticket.turnSerial) return;
103
118
  state.creations = Math.max(0, state.creations - 1);
104
- if (ticket.kind === "replace") state.replacements = Math.max(0, state.replacements - 1);
119
+ if (ticket.kind === "replace") {
120
+ state.replacements = Math.max(0, state.replacements - 1);
121
+ state.failedReplacementAttempts += 1;
122
+ }
105
123
  }
106
124
 
107
125
  function operationId(value) {
@@ -133,9 +151,11 @@ function attachPairTurnControl(ctx) {
133
151
  humanStopped: state.humanStopped,
134
152
  stoppedAt: state.stoppedAt,
135
153
  stoppedWorkerId: state.stoppedWorkerId,
154
+ stoppedWorkerIds: state.stoppedWorkerIds.slice(),
136
155
  turnSerial: state.serial,
137
156
  creationsThisTurn: state.creations,
138
157
  replacementsThisTurn: state.replacements,
158
+ failedReplacementAttemptsThisTurn: state.failedReplacementAttempts,
139
159
  };
140
160
  }
141
161
 
@@ -154,5 +174,6 @@ function attachPairTurnControl(ctx) {
154
174
  module.exports = {
155
175
  MAX_CREATIONS_PER_TURN: MAX_CREATIONS_PER_TURN,
156
176
  MAX_REPLACEMENTS_PER_TURN: MAX_REPLACEMENTS_PER_TURN,
177
+ MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN: MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN,
157
178
  attachPairTurnControl: attachPairTurnControl,
158
179
  };
@@ -0,0 +1,126 @@
1
+ function value(input) {
2
+ return typeof input === "string" && input ? input : null;
3
+ }
4
+
5
+ var splitRoles = require("./session-split-group-roles");
6
+
7
+ function pairFor(session, previousCli, previousOrigin) {
8
+ return {
9
+ cli: value(session && session.cliSessionId) || value(previousCli),
10
+ origin: value(session && session.sessionOriginId) || value(previousOrigin),
11
+ };
12
+ }
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
+
77
+ function memberAnchors(group, sessions) {
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 };
89
+ }
90
+
91
+ function pairAnchors(group, sessions) {
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);
95
+ var previousCli = Array.isArray(group.pairCliIds) ? group.pairCliIds : [null, null];
96
+ var previousOrigin = Array.isArray(group.pairOriginIds) ? group.pairOriginIds : [null, null];
97
+ var driver = pairFor(sessions.get(group.pair.driverId), previousCli[0], previousOrigin[0]);
98
+ var worker = pairFor(sessions.get(group.pair.workerId), previousCli[1], previousOrigin[1]);
99
+ return { cli: [driver.cli, worker.cli], origin: [driver.origin, worker.origin] };
100
+ }
101
+
102
+ function findSession(sessions, cliId, originId) {
103
+ var cli = value(cliId);
104
+ var origin = value(originId);
105
+ if (!cli && !origin) return null;
106
+ var found = null;
107
+ var ambiguous = false;
108
+ sessions.forEach(function (session) {
109
+ if (cli && session.cliSessionId !== cli) return;
110
+ if (origin && session.sessionOriginId !== origin) return;
111
+ if (found && found !== session) ambiguous = true;
112
+ else found = session;
113
+ });
114
+ return ambiguous ? null : found;
115
+ }
116
+
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 };