mcp-pacemaker 1.3.1 → 2.0.2

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.
@@ -1,162 +1,28 @@
1
1
  import fs from 'node:fs';
2
- import { createHash, randomUUID } from 'node:crypto';
3
- import { dirname, join, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import childProcess from 'node:child_process';
6
-
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';
7
10
  export const MAX_MIN_WARM = 32;
8
11
  export const MAX_UNDO_ENTRIES = 32;
9
12
  export const MAX_UNDO_BYTES = 8 * 1024 * 1024;
10
- export const MAX_CONFIG_BYTES = 1024 * 1024;
11
-
12
- const WINDOWS_SECURITY_HELPER = fileURLToPath(new URL('./windows/PoolingSecurityHelper.exe', import.meta.url));
13
-
14
- export class PoolingConfigError extends Error {
15
- constructor(statusCode, code, message) {
16
- super(message);
17
- this.name = 'PoolingConfigError';
18
- this.statusCode = statusCode;
19
- this.code = code;
20
- }
21
- }
22
-
23
- class JsonSpans {
24
- constructor(bytes) {
25
- this.text = bytes.toString('utf8');
26
- this.offset = 0;
27
- try {
28
- if (!Buffer.from(this.text).equals(bytes)) {
29
- throw new Error();
30
- }
31
- this.servers = JSON.parse(this.text);
32
- if (!this.servers ||
33
- Array.isArray(this.servers) ||
34
- typeof this.servers !== 'object') {
35
- throw new Error();
36
- }
37
- this.root = this.value(0);
38
- } catch {
39
- throw new PoolingConfigError(400, 'INVALID_CONFIG',
40
- 'Config must be a UTF-8 JSON object without duplicate keys or excessive nesting.');
41
- }
42
- }
43
-
44
- whitespace() {
45
- while (' \t\r\n'.includes(this.text[this.offset] ?? '\0')) {
46
- this.offset++;
47
- }
48
- }
49
-
50
- string() {
51
- const start = this.offset++;
52
- while (this.text[this.offset] !== '"') {
53
- this.offset += this.text[this.offset] === '\\' ? 2 : 1;
54
- }
55
- this.offset++;
56
- return JSON.parse(this.text.slice(start, this.offset));
57
- }
58
-
59
- value(depth) {
60
- if (depth > 128) {
61
- throw new Error();
62
- }
63
- this.whitespace();
64
- const start = this.offset;
65
- const token = this.text[this.offset];
66
- const properties = new Map();
67
- if (token === '{') {
68
- this.offset++;
69
- this.whitespace();
70
- while (this.text[this.offset] !== '}') {
71
- const keyStart = this.offset;
72
- const key = this.string();
73
- const keyEnd = this.offset;
74
- if (properties.has(key)) {
75
- throw new Error();
76
- }
77
- this.whitespace();
78
- this.offset++;
79
- const value = this.value(depth + 1);
80
- properties.set(key, { keyStart, keyEnd, value });
81
- this.whitespace();
82
- if (this.text[this.offset] !== ',') {
83
- break;
84
- }
85
- this.offset++;
86
- this.whitespace();
87
- }
88
- this.offset++;
89
- } else if (token === '[') {
90
- this.offset++;
91
- this.whitespace();
92
- while (this.text[this.offset] !== ']') {
93
- this.value(depth + 1);
94
- this.whitespace();
95
- if (this.text[this.offset] !== ',') {
96
- break;
97
- }
98
- this.offset++;
99
- }
100
- this.offset++;
101
- } else if (token === '"') {
102
- this.string();
103
- } else {
104
- while (this.offset < this.text.length &&
105
- !' \t\r\n,}]'.includes(this.text[this.offset])) {
106
- this.offset++;
107
- }
108
- }
109
- return { start, end: this.offset, properties };
110
- }
111
-
112
- update(name, changes) {
113
- const server = this.root.properties.get(name).value;
114
- const entries = [...server.properties.values()];
115
- const edits = [];
116
- const additions = [];
117
- for (const [key, value] of changes) {
118
- const property = server.properties.get(key);
119
- if (!property) {
120
- if (value !== undefined) {
121
- additions.push([key, value]);
122
- }
123
- continue;
124
- }
125
- if (value === undefined) {
126
- const index = entries.indexOf(property);
127
- const previous = entries[index - 1];
128
- const next = entries[index + 1];
129
- edits.push({
130
- start: previous ? previous.value.end : property.keyStart,
131
- end: previous ? property.value.end : next.keyStart,
132
- text: '',
133
- });
134
- } else if (this.servers[name][key] !== value) {
135
- edits.push({ start: property.value.start, end: property.value.end, text: JSON.stringify(value) });
136
- }
137
- }
138
- if (additions.length) {
139
- const first = entries[0];
140
- const last = entries.at(-1);
141
- const spacing = this.text.slice(server.start + 1, first.keyStart);
142
- const colon = this.text.slice(first.keyEnd, first.value.start);
143
- const text = additions.map(([key, value]) =>
144
- `,${spacing}${JSON.stringify(key)}${colon}${JSON.stringify(value)}`).join('');
145
- edits.push({ start: last.value.end, end: last.value.end, text });
146
- }
147
- let text = this.text;
148
- for (const edit of edits.sort((a, b) => b.start - a.start)) {
149
- text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
150
- }
151
- return Buffer.from(text);
152
- }
153
- }
13
+ export const POOLING_SAVE_WARNING =
14
+ 'New config copies inherit folder auditing. Custom per-file audit rules may not carry forward.';
154
15
 
155
16
  export class PoolingConfigStore {
156
17
  #configPath;
18
+ #files;
157
19
  #undos = new Map();
158
20
  #undoBytes = 0;
159
21
  #execution;
22
+ #pending;
23
+ #publication;
24
+ #closed = false;
25
+ #started = false;
160
26
 
161
27
  constructor(configPath) {
162
28
  if (typeof configPath !== 'string' ||
@@ -165,9 +31,10 @@ export class PoolingConfigStore {
165
31
  throw new PoolingConfigError(400, 'INVALID_PATH', 'A config file path is required.');
166
32
  }
167
33
  this.#configPath = resolve(configPath);
34
+ this.#files = new PoolingFiles(this.#configPath);
168
35
  }
169
36
 
170
- // Internal only: servers can contain credentials. Never serialize this through HTTP.
37
+ // Internal only: servers can contain credentials. Never serialize through HTTP.
171
38
  snapshot() {
172
39
  const current = this.#read();
173
40
  return { revision: current.revision, servers: new JsonSpans(current.bytes).servers };
@@ -177,25 +44,134 @@ export class PoolingConfigStore {
177
44
  return this.snapshot().revision;
178
45
  }
179
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
+
180
97
  apply(request, execution) {
181
- return this.#execute(() => this.#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
+ };
182
106
  }
183
107
 
184
108
  undo(request, execution) {
185
- return this.#execute(() => this.#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); }
186
124
  }
187
125
 
188
- #execute(operation, execution) {
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
+ }
189
132
  this.#execution = execution;
190
133
  try {
191
- execution?.check();
134
+ if (!committing) execution?.check();
192
135
  return operation();
136
+ } catch (error) {
137
+ const failure = poolingIOError(error);
138
+ if (committing) failure.commitState ??= 'not-committed';
139
+ throw failure;
193
140
  } finally {
194
141
  this.#execution = undefined;
195
142
  }
196
143
  }
197
144
 
198
- #apply(request) {
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) {
199
175
  this.#validateRequest(request, ['name', 'mode', 'minWarm', 'revision']);
200
176
  if (request.mode !== 'pool' &&
201
177
  request.mode !== 'isolated') {
@@ -207,10 +183,8 @@ export class PoolingConfigStore {
207
183
  }
208
184
  this.#validateMinWarm(request.minWarm);
209
185
  }
210
- this.#checkRevision(this.#read().revision, request.revision);
211
- const current = this.#read(true);
212
- this.#checkRevision(current.revision, request.revision);
213
- const document = new JsonSpans(current.bytes);
186
+ const current = this.#source(request.revision);
187
+ const document = new JsonSpans(this.#pending?.bytes ?? current.bytes);
214
188
  const server = this.#server(document.servers, request.name);
215
189
  const changes = new Map();
216
190
  if (request.mode === 'pool') {
@@ -219,45 +193,294 @@ export class PoolingConfigStore {
219
193
  changes.set('sharing', 'pool');
220
194
  changes.set('minWarm', minWarm);
221
195
  } else {
222
- if (Object.hasOwn(server, 'sharing')) {
223
- changes.set('sharing', 'isolated');
224
- }
196
+ if (Object.hasOwn(server, 'sharing')) changes.set('sharing', 'isolated');
225
197
  changes.set('minWarm', undefined);
226
198
  }
227
- const next = document.update(request.name, changes);
228
- if (next.length > MAX_CONFIG_BYTES) {
229
- throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Updated config would exceed 1 MiB.');
230
- }
231
- const state = this.#state(new JsonSpans(next).servers, request.name);
232
- if (next.equals(current.bytes)) {
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
+ }
233
211
  return { ok: true, ...state, revision: current.revision };
234
212
  }
235
- const revision = this.#hash(next);
236
- const undoId = randomUUID();
237
- this.#replace(current, next);
238
- this.#remember(undoId, { name: request.name, bytes: current.bytes, revision });
239
- return { ok: true, ...state, revision, undoId };
213
+ this.#stage(current, bytes, request.name);
214
+ return this.#receipt(state);
240
215
  }
241
216
 
242
- #undo(request) {
217
+ #stageUndo(request) {
243
218
  this.#validateRequest(request, ['name', 'undoId', 'revision']);
244
219
  if (typeof request.undoId !== 'string' ||
245
220
  !/^[0-9a-f-]{36}$/.test(request.undoId)) {
246
221
  throw new PoolingConfigError(400, 'INVALID_REQUEST', 'A valid undoId is required.');
247
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
+ }
248
234
  const record = this.#undos.get(request.undoId);
249
235
  if (!record ||
250
- record.name !== request.name) {
251
- throw new PoolingConfigError(409, 'UNDO_CONFLICT', 'Undo is unavailable. Reread the config.');
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.');
252
240
  }
253
241
  this.#checkRevision(request.revision, record.revision);
254
- this.#checkRevision(this.#read().revision, record.revision);
255
- const current = this.#read(true);
256
- this.#checkRevision(current.revision, record.revision);
242
+ const current = this.#source(record.revision);
243
+ this.#validateCandidate(record.bytes);
257
244
  const state = this.#state(new JsonSpans(record.bytes).servers, request.name);
258
- this.#replace(current, record.bytes);
259
- this.#forget(request.undoId);
260
- return { ok: true, ...state, revision: this.#hash(record.bytes) };
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.');
261
484
  }
262
485
 
263
486
  #validateRequest(request, allowed) {
@@ -326,215 +549,12 @@ export class PoolingConfigStore {
326
549
  Number.isSafeInteger(server.minWarm) &&
327
550
  server.minWarm >= 1 &&
328
551
  server.minWarm <= MAX_MIN_WARM;
329
- if (hasSafeMinWarm) {
330
- state.minWarm = server.minWarm;
331
- }
552
+ if (hasSafeMinWarm) state.minWarm = server.minWarm;
332
553
  return state;
333
554
  }
334
555
 
335
- #hash(bytes) {
336
- return createHash('sha256').update(bytes).digest('hex');
337
- }
338
-
339
556
  #checkRevision(actual, expected) {
340
- if (actual !== expected) {
341
- throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config changed. Reread it before applying or undoing.');
342
- }
343
- }
344
-
345
- #read(captureSecurity = false) {
346
- this.#execution?.check();
347
- let fd;
348
- try {
349
- const pathStat = fs.lstatSync(this.#configPath, { bigint: true });
350
- if (!pathStat.isFile() ||
351
- pathStat.nlink !== 1n) {
352
- throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config must be a regular file without links.');
353
- }
354
- const security = captureSecurity && process.platform === 'win32' ? this.#windowsSecurity() : undefined;
355
- fd = fs.openSync(this.#configPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
356
- const stat = fs.fstatSync(fd, { bigint: true });
357
- if (!stat.isFile() ||
358
- stat.nlink !== 1n ||
359
- stat.size > BigInt(MAX_CONFIG_BYTES)) {
360
- throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config must be a regular file of at most 1 MiB.');
361
- }
362
- if (pathStat.dev !== stat.dev ||
363
- pathStat.ino !== stat.ino) {
364
- throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config changed. Reread it before applying or undoing.');
365
- }
366
- const bytes = fs.readFileSync(fd);
367
- if (bytes.length > MAX_CONFIG_BYTES) {
368
- throw new PoolingConfigError(400, 'UNSUPPORTED_FILE', 'Config must be at most 1 MiB.');
369
- }
370
- return { bytes, stat, revision: this.#hash(bytes), security };
371
- } catch (error) {
372
- throw this.#ioError(error, 'read');
373
- } finally {
374
- if (fd !== undefined) {
375
- try {
376
- fs.closeSync(fd);
377
- } catch {
378
- throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to close the config file.');
379
- }
380
- }
381
- }
382
- }
383
-
384
- #checkCurrent(expected) {
385
- const current = this.#read();
386
- this.#checkRevision(current.revision, expected.revision);
387
- const fields = ['dev', 'ino', 'mode', 'uid', 'gid', 'nlink'];
388
- if (process.platform !== 'win32') fields.push('ctimeNs');
389
- const changedFields = fields
390
- .filter((key) => current.stat[key] !== expected.stat[key]);
391
- if (changedFields.length) {
392
- throw new PoolingConfigError(409, 'REVISION_CONFLICT',
393
- `Config file or permissions changed (${changedFields.join(', ')}). Reread the config.`);
394
- }
395
- // NTFS metadata updates can change ctime without changing the config or its permissions.
396
- if (process.platform === 'win32' &&
397
- current.stat.ctimeNs !== expected.stat.ctimeNs) {
398
- const security = this.#windowsSecurity();
399
- if (!expected.security.startsWith('F:') ||
400
- !security.startsWith('F:')) {
401
- throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config metadata changed and full security state could not be verified. Reread the config.');
402
- }
403
- if (security !== expected.security) {
404
- throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config permissions or file attributes changed. Reread the config.');
405
- }
406
- }
407
- }
408
-
409
- #runWindowsSecurity(operation, environment = {}) {
410
- const result = childProcess.spawnSync(WINDOWS_SECURITY_HELPER, [operation], {
411
- shell: false,
412
- windowsHide: true,
413
- stdio: ['ignore', 'pipe', 'pipe'],
414
- encoding: 'utf8',
415
- timeout: Math.min(10000, this.#execution?.remainingMs() ?? 10000),
416
- env: { ...process.env, MCP_POOL_SOURCE: this.#configPath, ...environment },
417
- });
418
- this.#execution?.check();
419
- if (result.status === 3) {
420
- throw new PoolingConfigError(409, 'REVISION_CONFLICT', 'Config permissions or file attributes changed. Reread the config.');
421
- }
422
- if (result.error ||
423
- result.status !== 0) {
424
- throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to inspect or preserve config file permissions.');
425
- }
426
- return result.stdout.trim();
427
- }
428
-
429
- #windowsSecurity() {
430
- const fingerprint = this.#runWindowsSecurity('inspect');
431
- const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
432
- const valid = fingerprint.length === 46 &&
433
- (fingerprint[0] === 'F' || fingerprint[0] === 'P') &&
434
- fingerprint[1] === ':' && fingerprint[45] === '=' &&
435
- [...fingerprint.slice(2, 45)].every((character) => alphabet.includes(character));
436
- if (!valid) {
437
- throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to inspect config file permissions.');
438
- }
439
- return fingerprint;
440
- }
441
-
442
- #permissions(files, current) {
443
- if (process.platform === 'win32') {
444
- // chmod on Windows cannot preserve a restrictive DACL. Copy it before writing secrets.
445
- this.#runWindowsSecurity('copy', {
446
- MCP_POOL_TEMP: files[0].path,
447
- MCP_POOL_BACKUP: files[1].path,
448
- MCP_POOL_SECURITY: current.security,
449
- });
450
- return;
451
- }
452
- const stat = current.stat;
453
- for (const file of files) {
454
- const created = fs.fstatSync(file.fd, { bigint: true });
455
- if (created.uid !== stat.uid ||
456
- created.gid !== stat.gid) {
457
- fs.fchownSync(file.fd, Number(stat.uid), Number(stat.gid));
458
- }
459
- }
460
- }
461
-
462
- #replace(current, bytes) {
463
- this.#execution?.check();
464
- if (process.platform === 'win32' &&
465
- !current.security.startsWith('F:')) {
466
- throw new PoolingConfigError(403, 'SECURITY_UNAVAILABLE',
467
- 'Automatic pooling edits require readable audit policy so security can be preserved. No config data was written.');
468
- }
469
- const files = [];
470
- try {
471
- for (let index = 0; index < 2; index++) {
472
- this.#execution?.check();
473
- const path = join(dirname(this.#configPath), `.pooling-${randomUUID()}.tmp`);
474
- const fd = fs.openSync(path, 'wx', 0o600);
475
- files.push({ path, fd, owned: true });
476
- }
477
- this.#permissions(files, current);
478
- for (const [index, file] of files.entries()) {
479
- this.#execution?.check();
480
- fs.writeFileSync(file.fd, index === 0 ? bytes : current.bytes);
481
- if (process.platform !== 'win32') {
482
- fs.fchmodSync(file.fd, Number(current.stat.mode & 0o7777n));
483
- }
484
- fs.fsyncSync(file.fd);
485
- fs.closeSync(file.fd);
486
- file.fd = undefined;
487
- }
488
- this.#checkCurrent(current);
489
- this.#execution?.check();
490
- fs.renameSync(files[1].path, `${this.#configPath}.bak`);
491
- files[1].owned = false;
492
- // No portable filesystem CAS: an external writer can still race this last check + rename.
493
- this.#checkCurrent(current);
494
- this.#execution?.beginCommit();
495
- fs.renameSync(files[0].path, this.#configPath);
496
- files[0].owned = false;
497
- } catch (error) {
498
- throw this.#ioError(error, 'write');
499
- } finally {
500
- this.#cleanup(files);
501
- }
502
- }
503
-
504
- #cleanup(files) {
505
- let failed = false;
506
- for (const file of files) {
507
- if (file.fd !== undefined) {
508
- try {
509
- fs.closeSync(file.fd);
510
- } catch {
511
- failed = true;
512
- }
513
- }
514
- if (file.owned) {
515
- try {
516
- fs.unlinkSync(file.path);
517
- } catch (error) {
518
- if (error.code !== 'ENOENT') {
519
- failed = true;
520
- }
521
- }
522
- }
523
- }
524
- if (failed) {
525
- throw new PoolingConfigError(500, 'IO_ERROR', 'Unable to clean up config transaction files.');
526
- }
527
- }
528
-
529
- #ioError(error, operation) {
530
- if (error instanceof PoolingConfigError) {
531
- return error;
532
- }
533
- if (operation === 'read' &&
534
- error.code === 'ENOENT') {
535
- return new PoolingConfigError(404, 'CONFIG_NOT_FOUND', 'Config file does not exist.');
536
- }
537
- return new PoolingConfigError(500, 'IO_ERROR', `Unable to ${operation} the config file.`);
557
+ if (actual !== expected) throw poolingConflict();
538
558
  }
539
559
 
540
560
  #remember(id, record) {
@@ -542,12 +562,16 @@ export class PoolingConfigStore {
542
562
  this.#undoBytes += record.bytes.length;
543
563
  while (this.#undos.size > MAX_UNDO_ENTRIES ||
544
564
  this.#undoBytes > MAX_UNDO_BYTES) {
545
- this.#forget(this.#undos.keys().next().value);
565
+ const oldest = [...this.#undos].find(([, entry]) => !entry.reserved);
566
+ if (!oldest) throw this.#undoConflict();
567
+ this.#forget(oldest[0]);
546
568
  }
547
569
  }
548
570
 
549
571
  #forget(id) {
550
- this.#undoBytes -= this.#undos.get(id).bytes.length;
572
+ const record = this.#undos.get(id);
573
+ if (!record) return;
574
+ this.#undoBytes -= record.bytes.length;
551
575
  this.#undos.delete(id);
552
576
  }
553
577
  }