mcp-pacemaker 2.0.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/CHANGELOG.md +452 -0
- package/CONTRIBUTING.md +222 -0
- package/LICENSE +21 -0
- package/README.md +621 -0
- package/SECURITY.md +120 -0
- package/THIRD_PARTY_NOTICES.txt +366 -0
- package/autostart/windows/launcher.vbs +11 -0
- package/autostart/windows/register-task.ps1 +63 -0
- package/bin/cli.mjs +880 -0
- package/bin/config-checks.mjs +62 -0
- package/bin/logs.mjs +465 -0
- package/bin/mcp-bridge.mjs +2162 -0
- package/bin/pooling-batch-scheduler.mjs +129 -0
- package/bin/pooling-batches.mjs +270 -0
- package/bin/pooling-config.mjs +577 -0
- package/bin/pooling-editor.mjs +128 -0
- package/bin/pooling-errors.mjs +23 -0
- package/bin/pooling-execution.mjs +104 -0
- package/bin/pooling-files.mjs +541 -0
- package/bin/pooling-trace.mjs +218 -0
- package/bin/pooling-writer.mjs +242 -0
- package/bin/prewarm.mjs +233 -0
- package/bin/server-metrics.mjs +69 -0
- package/bin/service-control.mjs +124 -0
- package/bin/shared-sessions.mjs +1083 -0
- package/bin/top.mjs +115 -0
- package/bin/update-check.mjs +153 -0
- package/bin/windows/PoolingSecurityHelper.build.json +42 -0
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/AssemblyInfo.cs +11 -0
- package/bin/windows/src/PoolingNativeFiles.cs +678 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +178 -0
- package/bin/windows/src/PoolingSecurityReader.cs +50 -0
- package/docs/shared-sessions.md +181 -0
- package/examples/servers.example.json +31 -0
- package/package.json +77 -0
- package/supervisor/bridge-child.mjs +14 -0
- package/supervisor/supervise.mjs +172 -0
- package/supervisor/supervise.ps1 +23 -0
- package/supervisor/supervise.sh +11 -0
- package/tools/windows-security-helper/README.md +238 -0
- package/tools/windows-security-helper/build.ps1 +262 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
- package/ui/dist/assets/index-CaLPg0c-.css +1 -0
- package/ui/dist/assets/index-CgPruWYi.js +9 -0
- package/ui/dist/index.html +14 -0
- package/ui/dist/third-party-manifest.json +597 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { JsonSpans } from './pooling-editor.mjs';
|
|
5
|
+
import { PoolingConfigError, poolingIOError, poolingConflict, recoveryError } from './pooling-errors.mjs';
|
|
6
|
+
import { PoolingFiles, readPoolingBytes, MAX_CONFIG_BYTES } from './pooling-files.mjs';
|
|
7
|
+
|
|
8
|
+
export { PoolingConfigError } from './pooling-errors.mjs';
|
|
9
|
+
export { recoverPoolingConfig, hasPoolingTransaction, MAX_CONFIG_BYTES } from './pooling-files.mjs';
|
|
10
|
+
export const MAX_MIN_WARM = 32;
|
|
11
|
+
export const MAX_UNDO_ENTRIES = 32;
|
|
12
|
+
export const MAX_UNDO_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
export const POOLING_SAVE_WARNING =
|
|
14
|
+
'New config copies inherit folder auditing. Custom per-file audit rules may not carry forward.';
|
|
15
|
+
|
|
16
|
+
export class PoolingConfigStore {
|
|
17
|
+
#configPath;
|
|
18
|
+
#files;
|
|
19
|
+
#undos = new Map();
|
|
20
|
+
#undoBytes = 0;
|
|
21
|
+
#execution;
|
|
22
|
+
#pending;
|
|
23
|
+
#publication;
|
|
24
|
+
#closed = false;
|
|
25
|
+
#started = false;
|
|
26
|
+
|
|
27
|
+
constructor(configPath) {
|
|
28
|
+
if (typeof configPath !== 'string' ||
|
|
29
|
+
!configPath ||
|
|
30
|
+
/[\x00-\x1f\x7f]/.test(configPath)) {
|
|
31
|
+
throw new PoolingConfigError(400, 'INVALID_PATH', 'A config file path is required.');
|
|
32
|
+
}
|
|
33
|
+
this.#configPath = resolve(configPath);
|
|
34
|
+
this.#files = new PoolingFiles(this.#configPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Internal only: servers can contain credentials. Never serialize through HTTP.
|
|
38
|
+
snapshot() {
|
|
39
|
+
const current = this.#read();
|
|
40
|
+
return { revision: current.revision, servers: new JsonSpans(current.bytes).servers };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
revision() {
|
|
44
|
+
return this.snapshot().revision;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
pendingSummary() {
|
|
48
|
+
const pending = this.#pending;
|
|
49
|
+
if (!pending) return null;
|
|
50
|
+
return {
|
|
51
|
+
id: pending.batchId, batchId: pending.batchId, generation: pending.generation,
|
|
52
|
+
revision: pending.base.revision, status: 'pending',
|
|
53
|
+
changes: pending.changes.map((change) => ({ ...change })),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stageApply(request, execution) {
|
|
58
|
+
this.#publication = undefined;
|
|
59
|
+
return this.#execute(() => this.#stageApply(request), execution);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
stageUndo(request, execution) {
|
|
63
|
+
this.#publication = undefined;
|
|
64
|
+
return this.#execute(() => this.#stageUndo(request), execution);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
rejectStage({ generation }, execution) {
|
|
68
|
+
return this.#execute(() => {
|
|
69
|
+
const publication = this.#publication;
|
|
70
|
+
if (!publication ||
|
|
71
|
+
publication.generation !== generation ||
|
|
72
|
+
this.#pending?.generation !== generation) {
|
|
73
|
+
return { ok: true, discarded: false };
|
|
74
|
+
}
|
|
75
|
+
const abandoned = this.#pending;
|
|
76
|
+
const prior = publication.prior;
|
|
77
|
+
if (prior) {
|
|
78
|
+
this.#files.save({
|
|
79
|
+
phase: 'updating', base: prior.base, draft: prior.draft, next: abandoned.draft,
|
|
80
|
+
previous: this.#files.record.previous,
|
|
81
|
+
});
|
|
82
|
+
this.#restoreDraft(prior, abandoned.draft);
|
|
83
|
+
} else {
|
|
84
|
+
this.#files.discard(this.#execution);
|
|
85
|
+
this.#releaseReservation(abandoned);
|
|
86
|
+
}
|
|
87
|
+
this.#pending = prior;
|
|
88
|
+
this.#publication = undefined;
|
|
89
|
+
return { ok: true, discarded: true };
|
|
90
|
+
}, execution);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
commitBatch(request, execution) {
|
|
94
|
+
return this.#execute(() => this.#commitBatch(request), execution, true);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
apply(request, execution) {
|
|
98
|
+
const staged = this.stageApply(request, execution);
|
|
99
|
+
if (!staged.pending) return staged;
|
|
100
|
+
const committed = this.commitBatch({ batchId: staged.batchId, generation: staged.generation }, execution);
|
|
101
|
+
const { name, mode, minWarm, undoId } = staged;
|
|
102
|
+
return {
|
|
103
|
+
ok: true, name, mode, ...(minWarm === undefined ? {} : { minWarm }),
|
|
104
|
+
revision: committed.revision, undoId,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
undo(request, execution) {
|
|
109
|
+
const staged = this.stageUndo(request, execution);
|
|
110
|
+
if (!staged.pending) return staged;
|
|
111
|
+
const committed = this.commitBatch({ batchId: staged.batchId, generation: staged.generation }, execution);
|
|
112
|
+
const { name, mode, minWarm } = staged;
|
|
113
|
+
return {
|
|
114
|
+
ok: true, name, mode, ...(minWarm === undefined ? {} : { minWarm }),
|
|
115
|
+
revision: committed.revision,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
close() {
|
|
120
|
+
this.#closed = true;
|
|
121
|
+
// All file operations are synchronous in the writer. Its message queue
|
|
122
|
+
// drains entered work before close; pending bytes and the journal remain.
|
|
123
|
+
try { this.#files.close(); } catch (error) { throw poolingIOError(error); }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#execute(operation, execution, committing = false) {
|
|
127
|
+
if (this.#closed) {
|
|
128
|
+
const error = new PoolingConfigError(503, 'WRITER_CLOSED', 'Config store is closed.');
|
|
129
|
+
if (committing) error.commitState = 'not-committed';
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
this.#execution = execution;
|
|
133
|
+
try {
|
|
134
|
+
if (!committing) execution?.check();
|
|
135
|
+
return operation();
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const failure = poolingIOError(error);
|
|
138
|
+
if (committing) failure.commitState ??= 'not-committed';
|
|
139
|
+
throw failure;
|
|
140
|
+
} finally {
|
|
141
|
+
this.#execution = undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#start() {
|
|
146
|
+
this.#execution?.trace?.record('store-start');
|
|
147
|
+
this.#execution?.check();
|
|
148
|
+
this.#files.acquire();
|
|
149
|
+
if (!this.#started) {
|
|
150
|
+
if (['entered', 'failed'].includes(this.#files.record?.phase)) throw recoveryError();
|
|
151
|
+
this.#files.recover(this.#execution);
|
|
152
|
+
this.#started = true;
|
|
153
|
+
} else if (!this.#pending &&
|
|
154
|
+
['pending', 'prepared'].includes(this.#files.record?.phase)) {
|
|
155
|
+
// A deadline can prevent cleanup. The next fresh operation may discard
|
|
156
|
+
// these owned, unentered bytes; it never replays the failed request.
|
|
157
|
+
this.#files.discard(this.#execution);
|
|
158
|
+
}
|
|
159
|
+
this.#execution?.trace?.record('store-ready');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#read() {
|
|
163
|
+
this.#execution?.check();
|
|
164
|
+
return readPoolingBytes(this.#configPath);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#source(revision) {
|
|
168
|
+
const current = this.#read();
|
|
169
|
+
this.#checkRevision(current.revision, revision);
|
|
170
|
+
if (this.#pending) this.#checkRevision(revision, this.#pending.base.revision);
|
|
171
|
+
return current;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#stageApply(request) {
|
|
175
|
+
this.#validateRequest(request, ['name', 'mode', 'minWarm', 'revision']);
|
|
176
|
+
if (request.mode !== 'pool' &&
|
|
177
|
+
request.mode !== 'isolated') {
|
|
178
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'Mode must be pool or isolated.');
|
|
179
|
+
}
|
|
180
|
+
if (Object.hasOwn(request, 'minWarm')) {
|
|
181
|
+
if (request.mode !== 'pool') {
|
|
182
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'minWarm is only valid for pool mode.');
|
|
183
|
+
}
|
|
184
|
+
this.#validateMinWarm(request.minWarm);
|
|
185
|
+
}
|
|
186
|
+
const current = this.#source(request.revision);
|
|
187
|
+
const document = new JsonSpans(this.#pending?.bytes ?? current.bytes);
|
|
188
|
+
const server = this.#server(document.servers, request.name);
|
|
189
|
+
const changes = new Map();
|
|
190
|
+
if (request.mode === 'pool') {
|
|
191
|
+
const minWarm = request.minWarm ?? server.minWarm ?? 1;
|
|
192
|
+
this.#validateMinWarm(minWarm);
|
|
193
|
+
changes.set('sharing', 'pool');
|
|
194
|
+
changes.set('minWarm', minWarm);
|
|
195
|
+
} else {
|
|
196
|
+
if (Object.hasOwn(server, 'sharing')) changes.set('sharing', 'isolated');
|
|
197
|
+
changes.set('minWarm', undefined);
|
|
198
|
+
}
|
|
199
|
+
const bytes = document.update(request.name, changes);
|
|
200
|
+
this.#validateCandidate(bytes);
|
|
201
|
+
const state = this.#state(new JsonSpans(bytes).servers, request.name);
|
|
202
|
+
if (bytes.equals(this.#pending?.bytes ?? current.bytes)) {
|
|
203
|
+
// A no-op is not a new acceptance and must not reset the parent's timer.
|
|
204
|
+
if (this.#pending) {
|
|
205
|
+
this.#verifyPending();
|
|
206
|
+
const activeState = this.#state(new JsonSpans(current.bytes).servers, request.name);
|
|
207
|
+
if (JSON.stringify(activeState) !== JSON.stringify(state)) {
|
|
208
|
+
return { ...this.#receipt(state), accepted: false };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { ok: true, ...state, revision: current.revision };
|
|
212
|
+
}
|
|
213
|
+
this.#stage(current, bytes, request.name);
|
|
214
|
+
return this.#receipt(state);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#stageUndo(request) {
|
|
218
|
+
this.#validateRequest(request, ['name', 'undoId', 'revision']);
|
|
219
|
+
if (typeof request.undoId !== 'string' ||
|
|
220
|
+
!/^[0-9a-f-]{36}$/.test(request.undoId)) {
|
|
221
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A valid undoId is required.');
|
|
222
|
+
}
|
|
223
|
+
const pending = this.#pending;
|
|
224
|
+
if (pending?.undoId === request.undoId) {
|
|
225
|
+
if (!pending.names.has(request.name)) throw this.#undoConflict();
|
|
226
|
+
const current = this.#source(request.revision);
|
|
227
|
+
this.#verifyPending();
|
|
228
|
+
this.#execution?.check();
|
|
229
|
+
this.#files.discard(this.#execution);
|
|
230
|
+
this.#releaseReservation(pending);
|
|
231
|
+
this.#pending = undefined;
|
|
232
|
+
return { ok: true, cancelled: true, batchId: pending.batchId, revision: current.revision };
|
|
233
|
+
}
|
|
234
|
+
const record = this.#undos.get(request.undoId);
|
|
235
|
+
if (!record ||
|
|
236
|
+
record.reserved ||
|
|
237
|
+
!record.names.has(request.name)) throw this.#undoConflict();
|
|
238
|
+
if (pending) {
|
|
239
|
+
throw new PoolingConfigError(409, 'PENDING_CONFLICT', 'Cancel or apply the current batch before undoing another batch.');
|
|
240
|
+
}
|
|
241
|
+
this.#checkRevision(request.revision, record.revision);
|
|
242
|
+
const current = this.#source(record.revision);
|
|
243
|
+
this.#validateCandidate(record.bytes);
|
|
244
|
+
const state = this.#state(new JsonSpans(record.bytes).servers, request.name);
|
|
245
|
+
this.#stage(current, record.bytes, request.name, record.names, request.undoId);
|
|
246
|
+
record.reserved = this.#pending.batchId;
|
|
247
|
+
return this.#receipt(state);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#validateCandidate(bytes) {
|
|
251
|
+
if (bytes.length > MAX_CONFIG_BYTES) {
|
|
252
|
+
throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Updated config would exceed 1 MiB.');
|
|
253
|
+
}
|
|
254
|
+
const document = new JsonSpans(bytes);
|
|
255
|
+
document.validateComplete();
|
|
256
|
+
return document;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#stage(current, bytes, name, names, restoring) {
|
|
260
|
+
this.#start();
|
|
261
|
+
const files = this.#files;
|
|
262
|
+
const prior = this.#pending;
|
|
263
|
+
if (prior) this.#verifyPending();
|
|
264
|
+
const base = prior?.base ?? files.inspect(this.#configPath, this.#execution);
|
|
265
|
+
this.#checkRevision(base.revision, current.revision);
|
|
266
|
+
const destination = prior ? files.paths.next : files.paths.pending;
|
|
267
|
+
let draft;
|
|
268
|
+
try {
|
|
269
|
+
draft = files.stage(destination, this.#configPath, base, bytes, this.#execution);
|
|
270
|
+
files.verify(this.#configPath, base, this.#execution);
|
|
271
|
+
this.#execution?.check();
|
|
272
|
+
if (prior) {
|
|
273
|
+
files.save({ ...files.record, phase: 'updating', next: draft });
|
|
274
|
+
files.move(files.paths.pending, files.paths.old, prior.draft, this.#execution);
|
|
275
|
+
files.move(files.paths.next, files.paths.pending, draft, this.#execution);
|
|
276
|
+
}
|
|
277
|
+
this.#execution?.check();
|
|
278
|
+
this.#execution?.trace?.record('publish-start');
|
|
279
|
+
files.save({
|
|
280
|
+
// Retain the prior native draft until this publication is durable.
|
|
281
|
+
// A later operation can clean it using the recorded identity.
|
|
282
|
+
phase: 'pending', base, draft, next: prior?.draft ?? null,
|
|
283
|
+
previous: files.record?.previous ?? null,
|
|
284
|
+
});
|
|
285
|
+
this.#execution?.trace?.record('publish-end');
|
|
286
|
+
} catch (error) {
|
|
287
|
+
// Before publication, only a returned, verified identity authorizes
|
|
288
|
+
// deleting the new file. Failed helper output is not ownership proof.
|
|
289
|
+
try {
|
|
290
|
+
if (prior && files.record?.phase === 'updating') {
|
|
291
|
+
this.#restoreDraft(prior, draft);
|
|
292
|
+
} else if (draft) {
|
|
293
|
+
files.remove(destination, draft, 1, this.#execution);
|
|
294
|
+
} else if (fs.existsSync(destination)) {
|
|
295
|
+
files.poisoned = true;
|
|
296
|
+
}
|
|
297
|
+
} catch (cleanupError) {
|
|
298
|
+
if (this.#abandoned(cleanupError)) {
|
|
299
|
+
if (draft && !files.poisoned) {
|
|
300
|
+
if (!prior) {
|
|
301
|
+
files.save({
|
|
302
|
+
phase: 'pending', base, draft, next: null,
|
|
303
|
+
previous: files.record?.previous ?? null,
|
|
304
|
+
});
|
|
305
|
+
} else if (files.record?.phase === 'pending') {
|
|
306
|
+
files.save({ ...files.record, next: draft });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
files.poisoned = true;
|
|
312
|
+
throw recoveryError();
|
|
313
|
+
}
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
const included = new Set(names ?? prior?.names ?? []);
|
|
317
|
+
included.add(name);
|
|
318
|
+
const document = new JsonSpans(bytes);
|
|
319
|
+
this.#pending = {
|
|
320
|
+
batchId: prior?.batchId ?? randomUUID(),
|
|
321
|
+
undoId: prior?.undoId ?? randomUUID(),
|
|
322
|
+
generation: randomUUID(),
|
|
323
|
+
base, draft, bytes, before: prior?.before ?? current.bytes, names: included,
|
|
324
|
+
changes: [...included].map((includedName) => this.#state(document.servers, includedName)),
|
|
325
|
+
restoring: prior?.restoring ?? restoring,
|
|
326
|
+
};
|
|
327
|
+
this.#publication = { generation: this.#pending.generation, prior };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#restoreDraft(prior, draft) {
|
|
331
|
+
const files = this.#files;
|
|
332
|
+
const topology = files.topology(this.#execution);
|
|
333
|
+
if (topology.pending?.identity === draft?.identity) files.remove(files.paths.pending, draft, 1, this.#execution);
|
|
334
|
+
if (fs.existsSync(files.paths.old)) {
|
|
335
|
+
files.move(files.paths.old, files.paths.pending, prior.draft, this.#execution);
|
|
336
|
+
}
|
|
337
|
+
if (fs.existsSync(files.paths.next)) files.remove(files.paths.next, draft, 1, this.#execution);
|
|
338
|
+
files.verify(files.paths.pending, prior.draft, this.#execution);
|
|
339
|
+
files.save({
|
|
340
|
+
phase: 'pending', base: prior.base, draft: prior.draft, next: null,
|
|
341
|
+
previous: files.record.previous,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#verifyPending() {
|
|
346
|
+
this.#files.acquire();
|
|
347
|
+
if (this.#files.record?.phase === 'updating') {
|
|
348
|
+
this.#restoreDraft(this.#pending, this.#files.record.next);
|
|
349
|
+
}
|
|
350
|
+
this.#files.verify(this.#configPath, this.#pending.base, this.#execution);
|
|
351
|
+
this.#files.verify(this.#files.paths.pending, this.#pending.draft, this.#execution);
|
|
352
|
+
if (this.#files.record?.phase === 'pending' &&
|
|
353
|
+
this.#files.record.next) {
|
|
354
|
+
for (const path of [this.#files.paths.old, this.#files.paths.next]) {
|
|
355
|
+
if (fs.existsSync(path)) {
|
|
356
|
+
this.#files.verify(path, this.#files.record.next, this.#execution);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const path of [this.#files.paths.old, this.#files.paths.next]) {
|
|
360
|
+
if (fs.existsSync(path)) {
|
|
361
|
+
this.#files.remove(path, this.#files.record.next, 1, this.#execution);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
this.#files.save({ ...this.#files.record, next: null });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#receipt(state) {
|
|
369
|
+
const pending = this.#pending;
|
|
370
|
+
return {
|
|
371
|
+
ok: true, ...state, revision: pending.base.revision, pending: true,
|
|
372
|
+
batchId: pending.batchId, undoId: pending.undoId, generation: pending.generation,
|
|
373
|
+
draftRevision: pending.draft.revision,
|
|
374
|
+
changes: pending.changes.map((change) => ({ ...change })),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#commitBatch(request) {
|
|
379
|
+
this.#publication = undefined;
|
|
380
|
+
const pending = this.#pending;
|
|
381
|
+
const validRequest = request &&
|
|
382
|
+
typeof request === 'object' &&
|
|
383
|
+
!Array.isArray(request) &&
|
|
384
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(request)) &&
|
|
385
|
+
Reflect.ownKeys(request).length === 2 &&
|
|
386
|
+
['batchId', 'generation'].every((key) =>
|
|
387
|
+
Object.hasOwn(Object.getOwnPropertyDescriptor(request, key) ?? {}, 'value'));
|
|
388
|
+
if (!pending ||
|
|
389
|
+
!validRequest ||
|
|
390
|
+
request.batchId !== pending.batchId ||
|
|
391
|
+
request.generation !== pending.generation) {
|
|
392
|
+
throw new PoolingConfigError(409, 'BATCH_CONFLICT', 'The pending batch generation changed.');
|
|
393
|
+
}
|
|
394
|
+
const files = this.#files;
|
|
395
|
+
let entered = false;
|
|
396
|
+
try {
|
|
397
|
+
this.#execution?.check();
|
|
398
|
+
this.#verifyPending();
|
|
399
|
+
const candidate = readPoolingBytes(files.paths.pending);
|
|
400
|
+
this.#validateCandidate(candidate.bytes);
|
|
401
|
+
this.#checkRevision(candidate.revision, pending.draft.revision);
|
|
402
|
+
const previous = files.record.previous;
|
|
403
|
+
files.save({ ...files.record, phase: 'prepared' });
|
|
404
|
+
if (previous) {
|
|
405
|
+
files.remove(files.paths.previous, previous, 1, this.#execution);
|
|
406
|
+
files.save({ ...files.record, previous: null });
|
|
407
|
+
} else if (fs.existsSync(files.paths.previous)) throw poolingConflict();
|
|
408
|
+
this.#verifyPending();
|
|
409
|
+
this.#execution?.check();
|
|
410
|
+
this.#execution?.beginCommit();
|
|
411
|
+
entered = true;
|
|
412
|
+
files.save({ ...files.record, phase: 'entered' });
|
|
413
|
+
files.move(this.#configPath, files.paths.previous, pending.base, this.#execution);
|
|
414
|
+
files.move(files.paths.pending, this.#configPath, pending.draft, this.#execution);
|
|
415
|
+
files.verify(this.#configPath, pending.draft, this.#execution);
|
|
416
|
+
files.verify(files.paths.previous, pending.base, this.#execution);
|
|
417
|
+
files.idle(pending.base);
|
|
418
|
+
this.#finish(pending);
|
|
419
|
+
return {
|
|
420
|
+
ok: true, batchId: pending.batchId, revision: pending.draft.revision,
|
|
421
|
+
changes: pending.changes.map((change) => ({ ...change })),
|
|
422
|
+
commitState: 'committed',
|
|
423
|
+
};
|
|
424
|
+
} catch (error) {
|
|
425
|
+
const failure = poolingIOError(error);
|
|
426
|
+
let commitState = entered ? 'unknown' : 'not-committed';
|
|
427
|
+
if (entered) {
|
|
428
|
+
try {
|
|
429
|
+
const topology = files.topology(this.#execution);
|
|
430
|
+
if (topology.active?.identity === pending.draft.identity &&
|
|
431
|
+
topology.active.revision === pending.draft.revision &&
|
|
432
|
+
topology.active.security === pending.draft.security &&
|
|
433
|
+
topology.previous?.identity === pending.base.identity &&
|
|
434
|
+
!topology.pending) {
|
|
435
|
+
commitState = 'committed';
|
|
436
|
+
} else if (topology.active?.identity === pending.base.identity &&
|
|
437
|
+
topology.active.revision === pending.base.revision &&
|
|
438
|
+
topology.active.security === pending.base.security &&
|
|
439
|
+
!topology.previous) {
|
|
440
|
+
commitState = 'not-committed';
|
|
441
|
+
}
|
|
442
|
+
} catch { /* Failed inspection leaves the outcome unknown. */ }
|
|
443
|
+
}
|
|
444
|
+
if (commitState === 'not-committed') {
|
|
445
|
+
try { files.discard(this.#execution); } catch (cleanupError) {
|
|
446
|
+
if (!this.#abandoned(cleanupError)) files.poisoned = true;
|
|
447
|
+
}
|
|
448
|
+
this.#releaseReservation(pending);
|
|
449
|
+
} else {
|
|
450
|
+
if (pending.restoring) this.#forget(pending.restoring);
|
|
451
|
+
try {
|
|
452
|
+
if (commitState === 'committed') files.idle(pending.base);
|
|
453
|
+
} catch { files.poisoned = true; }
|
|
454
|
+
// Keep the durable checkpoint for fresh, fully verified recovery.
|
|
455
|
+
// This writer cannot retry an outcome it could not establish.
|
|
456
|
+
if (commitState === 'unknown') files.poisoned = true;
|
|
457
|
+
}
|
|
458
|
+
this.#pending = undefined;
|
|
459
|
+
failure.commitState = commitState;
|
|
460
|
+
throw failure;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#finish(pending) {
|
|
465
|
+
if (pending.restoring) this.#forget(pending.restoring);
|
|
466
|
+
this.#remember(pending.undoId, {
|
|
467
|
+
names: pending.names, bytes: pending.before, revision: pending.draft.revision,
|
|
468
|
+
});
|
|
469
|
+
this.#pending = undefined;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
#releaseReservation(pending) {
|
|
473
|
+
const record = this.#undos.get(pending.restoring);
|
|
474
|
+
if (record?.reserved === pending.batchId) record.reserved = undefined;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#abandoned(error) {
|
|
478
|
+
return error?.code === 'WRITER_CANCELLED' ||
|
|
479
|
+
error?.code === 'WRITER_DEADLINE';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#undoConflict() {
|
|
483
|
+
return new PoolingConfigError(409, 'UNDO_CONFLICT', 'Undo is unavailable. Reread the config.');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
#validateRequest(request, allowed) {
|
|
487
|
+
if (!request ||
|
|
488
|
+
Array.isArray(request) ||
|
|
489
|
+
typeof request !== 'object') {
|
|
490
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A request object is required.');
|
|
491
|
+
}
|
|
492
|
+
const prototype = Object.getPrototypeOf(request);
|
|
493
|
+
if (prototype !== Object.prototype &&
|
|
494
|
+
prototype !== null) {
|
|
495
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A plain request object is required.');
|
|
496
|
+
}
|
|
497
|
+
for (const key of Reflect.ownKeys(request)) {
|
|
498
|
+
const descriptor = Object.getOwnPropertyDescriptor(request, key);
|
|
499
|
+
if (!allowed.includes(key) ||
|
|
500
|
+
!Object.hasOwn(descriptor, 'value')) {
|
|
501
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'Only pooling request fields are allowed.');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const validName = typeof request.name === 'string' &&
|
|
505
|
+
request.name.length > 0 &&
|
|
506
|
+
request.name.length <= 256 &&
|
|
507
|
+
!/[\x00-\x1f\x7f-\x9f]/.test(request.name) &&
|
|
508
|
+
!['__proto__', 'constructor', 'prototype'].includes(request.name);
|
|
509
|
+
if (!validName) {
|
|
510
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A valid server name is required.');
|
|
511
|
+
}
|
|
512
|
+
if (typeof request.revision !== 'string' ||
|
|
513
|
+
!/^[a-f0-9]{64}$/.test(request.revision)) {
|
|
514
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A SHA256 content revision is required.');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#validateMinWarm(value) {
|
|
519
|
+
if (!Number.isSafeInteger(value) ||
|
|
520
|
+
value < 1 ||
|
|
521
|
+
value > MAX_MIN_WARM) {
|
|
522
|
+
throw new PoolingConfigError(400, 'INVALID_REQUEST', `minWarm must be an integer from 1 to ${MAX_MIN_WARM}.`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
#server(servers, name) {
|
|
527
|
+
if (!Object.hasOwn(servers, name)) {
|
|
528
|
+
throw new PoolingConfigError(404, 'SERVER_NOT_FOUND', 'Server is not configured.');
|
|
529
|
+
}
|
|
530
|
+
const server = servers[name];
|
|
531
|
+
const isStdio = server &&
|
|
532
|
+
!Array.isArray(server) &&
|
|
533
|
+
typeof server === 'object' &&
|
|
534
|
+
(!Object.hasOwn(server, 'type') || server.type === 'stdio') &&
|
|
535
|
+
!Object.hasOwn(server, 'url') &&
|
|
536
|
+
server.sharing !== 'shared' &&
|
|
537
|
+
typeof server.command === 'string' &&
|
|
538
|
+
server.command.trim().length > 0;
|
|
539
|
+
if (!isStdio) {
|
|
540
|
+
throw new PoolingConfigError(400, 'UNSUPPORTED_SERVER', 'Only configured stdio servers support pooling.');
|
|
541
|
+
}
|
|
542
|
+
return server;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#state(servers, name) {
|
|
546
|
+
const server = this.#server(servers, name);
|
|
547
|
+
const state = { name, mode: server.sharing === 'pool' ? 'pool' : 'isolated' };
|
|
548
|
+
const hasSafeMinWarm = state.mode === 'pool' &&
|
|
549
|
+
Number.isSafeInteger(server.minWarm) &&
|
|
550
|
+
server.minWarm >= 1 &&
|
|
551
|
+
server.minWarm <= MAX_MIN_WARM;
|
|
552
|
+
if (hasSafeMinWarm) state.minWarm = server.minWarm;
|
|
553
|
+
return state;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
#checkRevision(actual, expected) {
|
|
557
|
+
if (actual !== expected) throw poolingConflict();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#remember(id, record) {
|
|
561
|
+
this.#undos.set(id, record);
|
|
562
|
+
this.#undoBytes += record.bytes.length;
|
|
563
|
+
while (this.#undos.size > MAX_UNDO_ENTRIES ||
|
|
564
|
+
this.#undoBytes > MAX_UNDO_BYTES) {
|
|
565
|
+
const oldest = [...this.#undos].find(([, entry]) => !entry.reserved);
|
|
566
|
+
if (!oldest) throw this.#undoConflict();
|
|
567
|
+
this.#forget(oldest[0]);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#forget(id) {
|
|
572
|
+
const record = this.#undos.get(id);
|
|
573
|
+
if (!record) return;
|
|
574
|
+
this.#undoBytes -= record.bytes.length;
|
|
575
|
+
this.#undos.delete(id);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { PoolingConfigError } from './pooling-errors.mjs';
|
|
2
|
+
|
|
3
|
+
// Span editing intentionally keeps unrequested bytes, spelling and whitespace.
|
|
4
|
+
export class JsonSpans {
|
|
5
|
+
constructor(bytes) {
|
|
6
|
+
this.text = bytes.toString('utf8');
|
|
7
|
+
this.offset = 0;
|
|
8
|
+
try {
|
|
9
|
+
if (!Buffer.from(this.text).equals(bytes)) throw new Error();
|
|
10
|
+
this.servers = JSON.parse(this.text);
|
|
11
|
+
if (!this.servers ||
|
|
12
|
+
Array.isArray(this.servers) ||
|
|
13
|
+
typeof this.servers !== 'object') throw new Error();
|
|
14
|
+
this.root = this.value(0);
|
|
15
|
+
} catch {
|
|
16
|
+
throw new PoolingConfigError(400, 'INVALID_CONFIG',
|
|
17
|
+
'Config must be a UTF-8 JSON object without duplicate keys or excessive nesting.');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
whitespace() {
|
|
22
|
+
while (' \t\r\n'.includes(this.text[this.offset] ?? '\0')) this.offset++;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
string() {
|
|
26
|
+
const start = this.offset++;
|
|
27
|
+
while (this.text[this.offset] !== '"') {
|
|
28
|
+
this.offset += this.text[this.offset] === '\\' ? 2 : 1;
|
|
29
|
+
}
|
|
30
|
+
this.offset++;
|
|
31
|
+
return JSON.parse(this.text.slice(start, this.offset));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
value(depth) {
|
|
35
|
+
if (depth > 128) throw new Error();
|
|
36
|
+
this.whitespace();
|
|
37
|
+
const start = this.offset;
|
|
38
|
+
const token = this.text[this.offset];
|
|
39
|
+
const properties = new Map();
|
|
40
|
+
if (token === '{') {
|
|
41
|
+
this.offset++;
|
|
42
|
+
this.whitespace();
|
|
43
|
+
while (this.text[this.offset] !== '}') {
|
|
44
|
+
const keyStart = this.offset;
|
|
45
|
+
const key = this.string();
|
|
46
|
+
const keyEnd = this.offset;
|
|
47
|
+
if (properties.has(key)) throw new Error();
|
|
48
|
+
this.whitespace();
|
|
49
|
+
this.offset++;
|
|
50
|
+
const value = this.value(depth + 1);
|
|
51
|
+
properties.set(key, { keyStart, keyEnd, value });
|
|
52
|
+
this.whitespace();
|
|
53
|
+
if (this.text[this.offset] !== ',') break;
|
|
54
|
+
this.offset++;
|
|
55
|
+
this.whitespace();
|
|
56
|
+
}
|
|
57
|
+
this.offset++;
|
|
58
|
+
} else if (token === '[') {
|
|
59
|
+
this.offset++;
|
|
60
|
+
this.whitespace();
|
|
61
|
+
while (this.text[this.offset] !== ']') {
|
|
62
|
+
this.value(depth + 1);
|
|
63
|
+
this.whitespace();
|
|
64
|
+
if (this.text[this.offset] !== ',') break;
|
|
65
|
+
this.offset++;
|
|
66
|
+
}
|
|
67
|
+
this.offset++;
|
|
68
|
+
} else if (token === '"') {
|
|
69
|
+
this.string();
|
|
70
|
+
} else {
|
|
71
|
+
while (this.offset < this.text.length &&
|
|
72
|
+
!' \t\r\n,}]'.includes(this.text[this.offset])) this.offset++;
|
|
73
|
+
}
|
|
74
|
+
return { start, end: this.offset, properties };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
update(name, changes) {
|
|
78
|
+
const server = this.root.properties.get(name).value;
|
|
79
|
+
const entries = [...server.properties.values()];
|
|
80
|
+
const edits = [];
|
|
81
|
+
const additions = [];
|
|
82
|
+
for (const [key, value] of changes) {
|
|
83
|
+
const property = server.properties.get(key);
|
|
84
|
+
if (!property) {
|
|
85
|
+
if (value !== undefined) additions.push([key, value]);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (value === undefined) {
|
|
89
|
+
const index = entries.indexOf(property);
|
|
90
|
+
const previous = entries[index - 1];
|
|
91
|
+
const next = entries[index + 1];
|
|
92
|
+
edits.push({
|
|
93
|
+
start: previous ? previous.value.end : property.keyStart,
|
|
94
|
+
end: previous ? property.value.end : next.keyStart,
|
|
95
|
+
text: '',
|
|
96
|
+
});
|
|
97
|
+
} else if (this.servers[name][key] !== value) {
|
|
98
|
+
edits.push({ start: property.value.start, end: property.value.end, text: JSON.stringify(value) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (additions.length) {
|
|
102
|
+
const first = entries[0];
|
|
103
|
+
const last = entries.at(-1);
|
|
104
|
+
const spacing = this.text.slice(server.start + 1, first.keyStart);
|
|
105
|
+
const colon = this.text.slice(first.keyEnd, first.value.start);
|
|
106
|
+
const text = additions.map(([key, value]) =>
|
|
107
|
+
`,${spacing}${JSON.stringify(key)}${colon}${JSON.stringify(value)}`).join('');
|
|
108
|
+
edits.push({ start: last.value.end, end: last.value.end, text });
|
|
109
|
+
}
|
|
110
|
+
let text = this.text;
|
|
111
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
112
|
+
text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
|
|
113
|
+
}
|
|
114
|
+
return Buffer.from(text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
validateComplete() {
|
|
118
|
+
for (const server of Object.values(this.servers)) {
|
|
119
|
+
if (!server ||
|
|
120
|
+
Array.isArray(server) ||
|
|
121
|
+
typeof server !== 'object' ||
|
|
122
|
+
(!server.command && !server.url)) {
|
|
123
|
+
throw new PoolingConfigError(400, 'INVALID_CONFIG',
|
|
124
|
+
'Every server definition must be an object with a command or URL.');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|