mcp-pacemaker 2.1.0 → 2.1.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +98 -2
  2. package/CONTRIBUTING.md +10 -3
  3. package/README.md +98 -760
  4. package/autostart/windows/legacy-access.ps1 +359 -0
  5. package/bin/account-upgrade-controller.mjs +1 -1
  6. package/bin/account-upgrade-worker.mjs +7 -0
  7. package/bin/cli-main.mjs +61 -5
  8. package/bin/dependency-lock.json +642 -0
  9. package/bin/legacy-access.mjs +73 -0
  10. package/bin/legacy-installation.mjs +75 -19
  11. package/bin/legacy-task.mjs +24 -0
  12. package/bin/legacy-upgrade.mjs +66 -10
  13. package/bin/managed-upgrade.mjs +102 -12
  14. package/bin/mcp-bridge.mjs +38 -20
  15. package/bin/prewarm.mjs +28 -6
  16. package/bin/service-control.mjs +10 -5
  17. package/bin/upgrade-capability.json +2 -1
  18. package/bin/upgrade-dependencies.mjs +67 -6
  19. package/bin/upgrade-package.mjs +2 -0
  20. package/bin/windows-legacy/LegacyProcessBroker.build.json +3 -3
  21. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  22. package/bin/windows-legacy/src/LegacyNative.cs +60 -11
  23. package/bin/windows-legacy/src/LegacyProcessBroker.cs +317 -197
  24. package/bin/windows-legacy-process.mjs +12 -5
  25. package/docs/account-upgrades.md +17 -4
  26. package/docs/compatibility.md +39 -0
  27. package/docs/configuration.md +146 -0
  28. package/docs/dashboards.md +125 -0
  29. package/docs/execution-context.md +11 -5
  30. package/docs/http-api.md +47 -0
  31. package/docs/install.md +52 -0
  32. package/docs/operations.md +99 -0
  33. package/docs/upgrades.md +324 -0
  34. package/package.json +10 -3
  35. package/tools/windows-legacy/README.md +118 -92
  36. package/tools/windows-legacy/inventory.mjs +8 -6
  37. package/ui/dist/third-party-manifest.json +1 -1
@@ -0,0 +1,73 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { durableJson } from './managed-state.mjs';
4
+ import { WindowsLegacyTaskAdapter } from './legacy-task.mjs';
5
+ import {
6
+ ACCESS_FIXES, legacyAccessProblems, legacyAccessReport, legacyStatePort, parseLegacyRegistration, verifyLegacyPackage,
7
+ } from './legacy-installation.mjs';
8
+
9
+ const NEXT = 'Run mcp-pacemaker upgrade --to <version> from a normal (non-administrator) terminal.';
10
+
11
+ export async function inspectLegacyAccess(home, { task = new WindowsLegacyTaskAdapter() } = {}) {
12
+ if (process.platform !== 'win32') throw new Error('--fix-legacy-access applies only to the known Windows 1.3 registration. Nothing was changed.');
13
+ const { port } = legacyStatePort(home);
14
+ const { root, registration } = parseLegacyRegistration(await task.inspect(port), port);
15
+ verifyLegacyPackage(root);
16
+ const cliPath = join(root, 'bin', 'cli.mjs');
17
+ const report = legacyAccessReport(await task.access(port, cliPath), registration);
18
+ return { port, cliPath, registration, report };
19
+ }
20
+
21
+ export function describeLegacyAccessFix({ port, report }) {
22
+ return [
23
+ `Legacy 1.3 access fix for port ${port}. It will only:`,
24
+ ...report.fixable.map(id => ` - ${ACCESS_FIXES[id](report)}`),
25
+ 'Unchanged: task XML, triggers and run-as account (it keeps running as you, not elevated), other permissions and file contents.',
26
+ ].join('\n');
27
+ }
28
+
29
+ // Elevated, explicit and bound to the inspected descriptors: makes an install done from an
30
+ // administrator terminal look like one done from a normal terminal, so the ordinary upgrade can run.
31
+ export async function fixLegacyAccess({
32
+ home, plan = false, yes = false, interactive = false, confirm,
33
+ task = new WindowsLegacyTaskAdapter(), log = console.log, now = () => new Date(),
34
+ } = {}) {
35
+ const selected = await inspectLegacyAccess(home, { task });
36
+ const { port, cliPath, registration, report } = selected;
37
+ const summary = { port, task: registration.path, cli: cliPath };
38
+ if (!report.missing.length) return { status: 'unchanged', ...summary, next: NEXT };
39
+ if (!report.elevated) {
40
+ throw new Error('Run --fix-legacy-access from an administrator terminal (Run as administrator). Nothing was changed.');
41
+ }
42
+ const manual = report.missing.filter(id => !report.fixable.includes(id));
43
+ if (manual.length) {
44
+ throw new Error([
45
+ 'These permissions need an administrator to change them by hand; --fix-legacy-access does not. Nothing was changed.',
46
+ ...legacyAccessProblems(report).map(problem => ` - ${problem}`),
47
+ ].join('\n'));
48
+ }
49
+ log(describeLegacyAccessFix(selected));
50
+ if (plan) return { status: 'planned', ...summary, changes: report.fixable };
51
+ if (!yes) {
52
+ if (!interactive) throw new Error('--fix-legacy-access needs an interactive confirmation or --yes. Nothing was changed.');
53
+ if (await confirm('Apply this access fix?') !== true) return { status: 'cancelled', ...summary };
54
+ }
55
+ const directory = join(home, 'legacy-upgrade');
56
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
57
+ const journal = join(directory, `access-${now().toISOString().replaceAll(':', '-')}.json`);
58
+ const record = {
59
+ protocol: 1, kind: 'legacy-access-fix', createdAt: now().toISOString(), ...summary,
60
+ changes: report.fixable, before: report.expected,
61
+ };
62
+ durableJson(journal, record);
63
+ let result;
64
+ try { result = await task.grantAccess(port, cliPath, report.expected); }
65
+ catch (error) {
66
+ durableJson(journal, { ...record, failed: error.message, changed: error.changed ?? [] });
67
+ throw new Error(`${error.message}\nRecorded before-state: ${journal}`, { cause: error });
68
+ }
69
+ durableJson(journal, { ...record, changed: result.changed, after: result.after });
70
+ const after = legacyAccessReport(await task.access(port, cliPath), registration);
71
+ if (after.missing.length) throw new Error(`Access is still missing after the fix: ${after.missing.join(', ')}. Recorded before-state: ${journal}`);
72
+ return { status: 'fixed', ...summary, changed: result.changed, journal, next: NEXT };
73
+ }
@@ -13,12 +13,21 @@ import { assertDurableRoot } from './service-control.mjs';
13
13
  const approved = new WeakMap();
14
14
  const inventory = readManagedJson(new URL('./legacy-1.3.json', import.meta.url));
15
15
  const hash = bytes => createHash('sha256').update(bytes).digest('hex');
16
+ const ACCESS_PROBLEMS = {
17
+ 'task-write': report => `Task ${report.task.path} (owner ${report.task.ownerName}): your account can only read it.`,
18
+ 'cli-owner': report => `${report.cli.path} (owner ${report.cli.ownerName}): your account cannot copy it with its permissions.`,
19
+ 'cli-write': report => `${report.cli.path}: your account cannot write it.`,
20
+ 'cli-delete': report => `${report.cli.path}: your account cannot rename it.`,
21
+ 'bin-add-file': report => `${report.cli.directory}: your account cannot create files in it.`,
22
+ };
23
+ export const ACCESS_FIXES = {
24
+ 'task-write': report => `give your account full control of task ${report.task.path}`,
25
+ 'cli-owner': report => `make your account the owner of ${report.cli.path}`,
26
+ };
16
27
  export const LEGACY_WARNING = [
17
- 'Restart this legacy Pacemaker installation? Pause its callers before confirming.',
18
- 'Clients will disconnect. Legacy 1.3 cannot drain or certify every in-flight HTTP outcome.',
19
- 'Only the selected supervisor, bridge and captured provably owned descendants will be verified stopped.',
20
- 'Historical orphan coverage remains unproven; uncaptured or unattributed processes are left untouched and are not enumerated as a complete set.',
21
- 'No unknown call is automatically replayed. The new managed runtime contains future children from birth.',
28
+ 'Upgrade this 1.3 installation? Its supervisor, its bridge and the processes the bridge started will be stopped.',
29
+ 'Open sessions and calls in progress end, and clients reconnect to the new version. No call is retried.',
30
+ 'servers.json, every server setting in it and your client setup are kept as they are.',
22
31
  ].join('\n');
23
32
 
24
33
  export function legacyPlanDigest(plan) {
@@ -30,18 +39,19 @@ export function legacyPlanDigest(plan) {
30
39
 
31
40
  export async function confirmLegacyUpgrade(plan, { yes = false, interactive = false, confirm } = {}) {
32
41
  if (!plan.legacy) throw new Error('Expected a legacy restart plan.');
33
- if (yes ||
34
- !interactive) throw new Error('Legacy migration requires interactive acknowledgement of this exact restart/uncertain-HTTP/partial-tree plan. Run upgrade --to <version> without --yes in an interactive terminal; pause callers before confirming.');
35
42
  const digest = legacyPlanDigest(plan);
36
- const answer = await confirm(`${LEGACY_WARNING}\nPlan: ${digest}`);
37
- if (answer !== true) return false;
43
+ if (!yes) {
44
+ if (!interactive) throw new Error('Legacy migration needs confirmation. Run upgrade --to <version> in an interactive terminal, or add --yes to confirm the plan shown.');
45
+ const answer = await confirm(`${LEGACY_WARNING}\nPlan: ${digest}`);
46
+ if (answer !== true) return false;
47
+ }
38
48
  if (legacyPlanDigest(plan) !== digest) throw new Error('Legacy plan changed during confirmation.');
39
49
  approved.set(plan, digest);
40
50
  return true;
41
51
  }
42
52
 
43
53
  export function requireLegacyApproval(plan) {
44
- if (approved.get(plan) !== legacyPlanDigest(plan)) throw new Error('This legacy plan has no matching interactive acknowledgement.');
54
+ if (approved.get(plan) !== legacyPlanDigest(plan)) throw new Error('This legacy plan has no matching confirmation.');
45
55
  approved.delete(plan);
46
56
  }
47
57
 
@@ -132,10 +142,58 @@ export function legacySessionState(config) {
132
142
  return existsSync(path) ? new PoolingFiles(config).inspect(path) : null;
133
143
  }
134
144
 
145
+ export function legacyStatePort(home) {
146
+ const statePath = join(home, 'state.json');
147
+ if (!existsSync(statePath)) throw new Error('No stable managed installation or exact legacy install state was found. Nothing was changed.');
148
+ const state = readManagedJson(statePath);
149
+ const ports = [...new Set((state.hosts ?? []).map(host => canonicalPort(host.port)))];
150
+ if (ports.length !== 1) throw new Error('Legacy migration requires exactly one selected port in existing install state.');
151
+ return { statePath, port: ports[0] };
152
+ }
153
+
154
+ export function legacyAccessReport(report, registration) {
155
+ const bound = report?.protocol === 1 &&
156
+ report.task?.path === registration.path &&
157
+ report.expected?.xmlSha256 === registration.xmlSha256 &&
158
+ Array.isArray(report.missing) &&
159
+ Array.isArray(report.fixable) &&
160
+ report.missing.every(id => Object.hasOwn(ACCESS_PROBLEMS, id)) &&
161
+ report.fixable.every(id => Object.hasOwn(ACCESS_FIXES, id) && report.missing.includes(id));
162
+ if (!bound) throw new Error('Legacy access check did not match the selected registration. Nothing was changed.');
163
+ return report;
164
+ }
165
+
166
+ export function legacyAccessProblems(report) {
167
+ return report.missing.map(id => ACCESS_PROBLEMS[id](report));
168
+ }
169
+
170
+ // A certain gap here would otherwise fail after the journal is published (task hold) or after staging (CLI copy).
171
+ export function assertLegacyAccess(report, registration) {
172
+ legacyAccessReport(report, registration);
173
+ if (!report.missing.length) return;
174
+ const lines = [
175
+ 'Your account cannot make the changes this upgrade needs. Nothing was changed.',
176
+ ...legacyAccessProblems(report).map(problem => ` - ${problem}`),
177
+ ];
178
+ if (report.fixable.length) {
179
+ lines.push(
180
+ 'This usually means 1.3 was set up from an administrator terminal.',
181
+ `Run this once from ${report.elevated ? 'this' : 'an'} administrator terminal. It will only ${report.fixable.map(id => ACCESS_FIXES[id](report)).join(' and ')}:`,
182
+ ' mcp-pacemaker upgrade --fix-legacy-access',
183
+ 'Then run this upgrade again from a normal (non-administrator) terminal.',
184
+ );
185
+ }
186
+ if (report.missing.some(id => !report.fixable.includes(id))) {
187
+ lines.push('--fix-legacy-access does not change the other permissions listed; an administrator must grant them.');
188
+ }
189
+ throw Object.assign(new Error(lines.join('\n')), { code: 'LEGACY_ACCESS_MISSING' });
190
+ }
191
+
192
+ // 1.3 rewrites sessions.json whenever a session starts or ends, and an upgrade may end
193
+ // sessions, so only the config is compared.
135
194
  export function verifyLegacyConfig(config, legacy) {
136
195
  new PoolingFiles(config).verify(config, legacy.authority);
137
- if (!isDeepStrictEqual(legacySessionState(config), legacy.sessions) ||
138
- hasPoolingTransaction(config)) throw new Error('Legacy configuration/session state changed or has an unresolved transaction.');
196
+ if (hasPoolingTransaction(config)) throw new Error('Legacy configuration has an unresolved transaction.');
139
197
  }
140
198
 
141
199
  async function nonceChallenge(port, nonce) {
@@ -183,21 +241,19 @@ export async function verifyLegacyListener(instance, legacy, { sameGeneration =
183
241
  export async function prepareLegacyPlan(options, { home, task = new WindowsLegacyTaskAdapter(), processes } = {}) {
184
242
  if (process.platform !== 'win32') throw new Error('Legacy restart migration has no supported adapter on this OS. Nothing was changed.');
185
243
  if (options.sxs) throw new Error('Legacy-source SxS is not supported; no existing service was changed.');
186
- const statePath = join(home, 'state.json');
187
- if (!existsSync(statePath)) throw new Error('No stable managed installation or exact legacy install state was found. Nothing was changed.');
188
- const state = readManagedJson(statePath);
189
- const ports = [...new Set((state.hosts ?? []).map(host => canonicalPort(host.port)))];
190
- if (ports.length !== 1) throw new Error('Legacy migration requires exactly one selected port in existing install state.');
191
- const port = ports[0];
244
+ const { statePath, port } = legacyStatePort(home);
192
245
  const records = task.inspectLegacySource ? await task.inspectLegacySource(port) : await task.inspect(port);
193
246
  const { root, registration } = parseLegacyRegistration(records, port);
194
247
  const rootIdentity = verifyLegacyPackage(root);
248
+ if (typeof task.access === 'function') assertLegacyAccess(await task.access(port, join(root, 'bin', 'cli.mjs')), registration);
195
249
  const config = realpathSync.native(join(home, 'servers.json'));
196
250
  const files = new PoolingFiles(config);
251
+ // 1.3 rewrites sessions.json on every session start and end, so it is read only after the
252
+ // old bridge has stopped.
197
253
  const legacy = {
198
254
  protocol: 1, root, rootIdentity, registration,
199
255
  stateAuthority: files.inspect(statePath),
200
- authority: files.inspect(config), sessions: legacySessionState(config),
256
+ authority: files.inspect(config), sessions: null,
201
257
  };
202
258
  const instance = {
203
259
  id: randomUUID(), directory: join(home, 'managed', `legacy-${port}-${randomUUID()}`),
@@ -33,6 +33,30 @@ export class WindowsLegacyTaskAdapter {
33
33
  repoint(port, expected, launcher) { return this.call('repoint', port, { expected, launcher }); }
34
34
  enable(port, expected) { return this.call('enable', port, { expected }); }
35
35
  restore(port, expected, original) { return this.call('restore', port, { expected, original }); }
36
+
37
+ callAccess(operation, port, cliPath, input) {
38
+ if (process.platform !== 'win32') throw new Error('Legacy access checks are supported only for the known Windows 1.3 registration.');
39
+ const script = fileURLToPath(new URL('../autostart/windows/legacy-access.ps1', import.meta.url));
40
+ let output;
41
+ try {
42
+ output = execFileSync('pwsh', [
43
+ '-NoProfile', '-NonInteractive', '-File', script, '-Operation', operation, '-Port', String(port), '-CliPath', cliPath,
44
+ ], {
45
+ input: input ? JSON.stringify(input) : '', encoding: 'utf8',
46
+ windowsHide: true, timeout: 30000, maxBuffer: 1024 * 1024,
47
+ });
48
+ } catch (error) {
49
+ let reply;
50
+ try { reply = JSON.parse(String(error.stdout ?? '')); } catch { throw error; }
51
+ throw Object.assign(new Error(String(reply.error)), { changed: Array.isArray(reply.changed) ? reply.changed : [] });
52
+ }
53
+ return JSON.parse(output);
54
+ }
55
+
56
+ // Read-only: what the ordinary runtime user cannot do to the task and bin/cli.mjs.
57
+ access(port, cliPath) { return this.callAccess('inspect', port, cliPath); }
58
+ // Elevated only: fixes the gaps reported by access(), bound to its returned descriptors.
59
+ grantAccess(port, cliPath, expected) { return this.callAccess('grant', port, cliPath, { expected }); }
36
60
  assertRestored(port, actual, original) {
37
61
  const { xml: actualXml, xmlSha256: actualHash, ...actualFields } = actual;
38
62
  const { xml: originalXml, xmlSha256: originalHash, ...originalFields } = original;
@@ -1,21 +1,22 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { isDeepStrictEqual } from 'node:util';
6
+ import semver from 'semver';
6
7
  import {
7
8
  acquireInstanceLock, createInstance, directoryIdentity, durableJson,
8
9
  readInstanceState, readManagedJson, readRecoveryContext, snapshotPackage, verifySelection,
9
10
  legacyMigrationPhases,
10
11
  } from './managed-state.mjs';
11
12
  import { inspectInstance, listenerOpen, startInstance, stopInstance } from './managed-runtime.mjs';
12
- import { PoolingFiles } from './pooling-files.mjs';
13
+ import { MAX_CONFIG_BYTES, PoolingFiles } from './pooling-files.mjs';
13
14
  import { registerDefaultCli } from './cli-dispatch.mjs';
14
15
  import { servicePaths } from './service-control.mjs';
15
16
  import { WindowsLegacyTaskAdapter } from './legacy-task.mjs';
16
17
  import { requireUserRuntimeContext } from './runtime-context.mjs';
17
18
  import {
18
- legacyPlanDigest, parseLegacyRegistration, requireLegacyApproval, verifyLegacyConfig, verifyLegacyListener, verifyLegacyPackage,
19
+ legacyPlanDigest, legacySessionState, parseLegacyRegistration, requireLegacyApproval, verifyLegacyConfig, verifyLegacyListener, verifyLegacyPackage,
19
20
  } from './legacy-installation.mjs';
20
21
 
21
22
  export const legacyPhases = legacyMigrationPhases;
@@ -148,6 +149,7 @@ export class LegacyUpgrader {
148
149
  await requireUserRuntimeContext();
149
150
  const approvalDigest = legacyPlanDigest(plan);
150
151
  requireLegacyApproval(plan);
152
+ this.requireSessionRecords(plan.instance.config, plan.to);
151
153
  this.verifySource(plan.instance, plan.legacy);
152
154
  await this.taskMatches(plan.instance, plan.legacy.registration);
153
155
  const operation = randomUUID();
@@ -174,6 +176,8 @@ export class LegacyUpgrader {
174
176
  const target = await this.stage({ ...plan, instance: { ...plan.instance, directory: work } }, operation);
175
177
  const capability = readManagedJson(join(target.root, 'bin', 'upgrade-capability.json'));
176
178
  if (capability.legacyRestartProtocol !== 1) throw new Error('Target does not support legacy restart recovery; old service was not touched.');
179
+ // The records can grow while the target downloads, so their size is checked again.
180
+ this.requireSessionRecords(plan.instance.config, target.version);
177
181
  const recovery = snapshotPackage(target.root, join(work, 'recovery'));
178
182
  verifySelection(recovery);
179
183
  this.verifySource(plan.instance, plan.legacy);
@@ -247,7 +251,12 @@ export class LegacyUpgrader {
247
251
  const legacyStop = await session.stop();
248
252
  this.transition(instance, journal, 'legacy-stopping', { legacyStop });
249
253
  this.assertPartialStop(legacyStop);
250
- this.transition(instance, journal, 'legacy-stopped', { legacyStop });
254
+ // The 1.3 bridge has stopped, so its session records no longer change. An older target
255
+ // still compares them at admission.
256
+ const sessions = this.sessionRecords(instance.config);
257
+ this.transition(instance, journal, 'legacy-stopped', {
258
+ legacyStop, sessionState: sessions, legacy: { ...journal.legacy, sessions },
259
+ });
251
260
  return await this.activate(instance, journal);
252
261
  } catch (error) {
253
262
  if (journal &&
@@ -268,14 +277,53 @@ export class LegacyUpgrader {
268
277
  }
269
278
  }
270
279
 
280
+ // Targets before 2.1.1 compare session records when they start, reading at most 1 MiB of them.
281
+ comparesSessionRecords(version) {
282
+ const ignoresRecords = semver.valid(version) &&
283
+ semver.gte(version, '2.1.1');
284
+ return !ignoresRecords;
285
+ }
286
+
287
+ // Refuse such a target before anything is downloaded or stopped instead of restarting into a
288
+ // rollback. Only the size is checked, so a session starting or ending meanwhile cannot make
289
+ // this refuse.
290
+ requireSessionRecords(config, version) {
291
+ if (!this.comparesSessionRecords(version)) return;
292
+ const path = join(dirname(config), 'sessions.json');
293
+ const size = statSync(path, { throwIfNoEntry: false })?.size ?? 0;
294
+ if (size > MAX_CONFIG_BYTES) {
295
+ throw new Error(`Session records in ${path} are over 1 MiB, more than ${version} can compare when it starts. Upgrade to 2.1.1 or newer instead. Nothing was stopped.`);
296
+ }
297
+ }
298
+
299
+ // Records that could not be read after the stop would make such a target refuse admission,
300
+ // where rollback is no longer automatic. Refusing here, while the journal is still at
301
+ // `legacy-stopped`, restarts 1.3.
302
+ requireReadSessionRecords(config, journal) {
303
+ const unread = this.comparesSessionRecords(journal.target.version) &&
304
+ journal.sessionState === null &&
305
+ existsSync(join(dirname(config), 'sessions.json'));
306
+ if (unread) {
307
+ throw new Error(`Session records could not be read after the old version stopped, and ${journal.target.version} compares them when it starts.`);
308
+ }
309
+ }
310
+
311
+ // Read once the 1.3 bridge has stopped. A file the integrity helper refuses, such as one
312
+ // over 1 MiB, must not strand an upgrade whose old version is already down.
313
+ sessionRecords(config) {
314
+ try { return legacySessionState(config); }
315
+ catch { return null; }
316
+ }
317
+
318
+ // An upgrade may end the old bridge's sessions; only the supervisor and bridge must be proven
319
+ // stopped. A process the bridge started that could not be stopped is reported, not a refusal.
271
320
  assertPartialStop(receipt) {
272
321
  if (receipt?.legacyRootStopVerified !== true ||
273
- receipt.observedDescendantsStopped !== true ||
322
+ typeof receipt.observedDescendantsStopped !== 'boolean' ||
274
323
  receipt.treeCompleteness !== 'unproven' ||
275
324
  !Array.isArray(receipt.errors) ||
276
- receipt.errors.length ||
277
325
  'stopped' in receipt ||
278
- 'activeProcesses' in receipt) throw new Error('Legacy root/observed-set shutdown is incomplete. Registration remains held; no activation was authorized.');
326
+ 'activeProcesses' in receipt) throw new Error('Legacy supervisor and bridge shutdown is not verified. Registration remains held; no activation was authorized.');
279
327
  }
280
328
 
281
329
  async verifyOldStopped(instance, journal) {
@@ -286,6 +334,7 @@ export class LegacyUpgrader {
286
334
  }
287
335
 
288
336
  async activate(instance, journal) {
337
+ this.requireReadSessionRecords(instance.config, journal);
289
338
  await this.verifyOldStopped(instance, journal);
290
339
  this.verifySource(instance, journal.legacy);
291
340
  await this.taskMatches(instance, journal.legacy.taskCurrent);
@@ -317,10 +366,16 @@ export class LegacyUpgrader {
317
366
  return {
318
367
  status: 'upgraded', directory: instance.directory, port: instance.port, version: journal.target.version,
319
368
  legacyStop: journal.legacyStop,
320
- warning: 'Legacy HTTP outcomes were not certified or replayed. Historical orphan coverage is unproven; unattributed processes were left untouched.',
369
+ warning: this.stopWarning(journal.legacyStop),
321
370
  };
322
371
  }
323
372
 
373
+ stopWarning(receipt) {
374
+ const ended = 'Open 1.3 sessions and calls in progress were ended; clients reconnect. No call was retried.';
375
+ if (receipt.observedDescendantsStopped) return ended;
376
+ return `${ended} Not every process the 1.3 bridge started could be confirmed stopped; see legacyStop.errors.`;
377
+ }
378
+
324
379
  selectCli(instance, journal) {
325
380
  const cli = journal.legacy.cli;
326
381
  const files = new PoolingFiles(cli.path);
@@ -407,7 +462,8 @@ export class LegacyUpgrader {
407
462
  this.assertRestoredTask(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
408
463
  this.transition(instance, journal, 'legacy-rollback-launching');
409
464
  await this.task.start(instance.port, journal.legacy.taskCurrent);
410
- const deadline = Date.now() + RESTART_OBSERVATION_MS;
465
+ const started = Date.now();
466
+ const deadline = started + RESTART_OBSERVATION_MS;
411
467
  let lastError;
412
468
  while (Date.now() < deadline) {
413
469
  try {
@@ -424,7 +480,7 @@ export class LegacyUpgrader {
424
480
  await new Promise(resolveWait => setTimeout(resolveWait, 100));
425
481
  }
426
482
  throw new Error('Legacy restart outcome is uncertain; retained code, task and journal require inspection.' +
427
- (lastError ? ` Last observation after ${RESTART_OBSERVATION_MS} ms: ${lastError.message}` : ''),
483
+ (lastError ? ` Last observation after ${Date.now() - started} ms: ${lastError.message}` : ''),
428
484
  { cause: lastError });
429
485
  }
430
486
 
@@ -1,15 +1,16 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
+ import semver from 'semver';
6
7
  import {
7
8
  acquireInstanceLock, createInstance, directoryIdentity, durableJson,
8
9
  loadInstance, readInstanceState, readRecoveryContext, readManagedJson, verifySelection,
9
10
  } from './managed-state.mjs';
10
11
  import { acquireUpgrade, configuredRegistry, exactVersion, unpackUpgrade, verifyArchive } from './upgrade-package.mjs';
11
12
  import { inspectInstance, listenerOpen, startInstance, stopInstance } from './managed-runtime.mjs';
12
- import { PoolingFiles, hasPoolingTransaction } from './pooling-files.mjs';
13
+ import { MAX_CONFIG_BYTES, PoolingFiles, hasPoolingTransaction } from './pooling-files.mjs';
13
14
  import { inspectAutostart } from './upgrade-autostart.mjs';
14
15
  import { validateRegistry } from './update-check.mjs';
15
16
  import { servicePaths } from './service-control.mjs';
@@ -26,9 +27,10 @@ function sessionState(config) {
26
27
  return existsSync(path) ? new PoolingFiles(config).inspect(path) : null;
27
28
  }
28
29
 
30
+ // Session records change whenever a client connects. An upgrade may end sessions, so only
31
+ // the config is protected here.
29
32
  function verifyRollbackState(instance, journal) {
30
33
  new PoolingFiles(instance.config).verify(instance.config, journal.authority);
31
- if (!same(sessionState(instance.config), journal.sessionState)) throw new Error('Persisted session state changed; rollback/activation refused.');
32
34
  if (hasPoolingTransaction(instance.config)) throw new Error('Config outcome is uncertain; automatic rollback refused.');
33
35
  }
34
36
 
@@ -105,9 +107,9 @@ export async function planUpgrade(options, { home, legacy, autostart: inspectSta
105
107
  affectedInstances: [prepared.legacy.backendInstanceId],
106
108
  autostart: { kind: 'legacy', name: prepared.legacy.registration.name },
107
109
  downtime: LEGACY_WARNING,
108
- rollback: 'Only before target admission, with unchanged config/session authority and verified captured-root/observed-set stop. Uncertain launch, task or HTTP outcomes require explicit recovery.',
110
+ rollback: 'Only before target admission, with unchanged config and a verified supervisor and bridge stop. Uncertain launch or task outcomes require explicit recovery.',
109
111
  hostWiring: 'Unchanged. The same installed CLI entry will dispatch the selected managed version.',
110
- legacyAdoption: 'One-time restart; tree completeness stays unproven. Unattributed historical processes are left untouched.',
112
+ legacyAdoption: 'One-time restart. A process the 1.3 bridge started that cannot be stopped is reported and left running.',
111
113
  };
112
114
  }
113
115
  if (instances.length !== 1) {
@@ -153,7 +155,7 @@ export function describeUpgrade(plan) {
153
155
  export class ManagedUpgrader {
154
156
  constructor({ acquire = acquireUpgrade, dependencies = stageCliDependencies, checkpoint = () => {}, legacy,
155
157
  autostart = inspectAutostart, beforeInterruption = async () => {},
156
- beforeStop = async () => {}, beforeAdmission = async () => {} } = {}) {
158
+ beforeStop = async () => {}, beforeAdmission = async () => {}, afterRefusal = async () => {} } = {}) {
157
159
  this.acquire = acquire;
158
160
  this.dependencies = dependencies;
159
161
  this.checkpoint = checkpoint;
@@ -162,6 +164,7 @@ export class ManagedUpgrader {
162
164
  this.beforeInterruption = beforeInterruption;
163
165
  this.beforeStop = beforeStop;
164
166
  this.beforeAdmission = beforeAdmission;
167
+ this.afterRefusal = afterRefusal;
165
168
  }
166
169
 
167
170
  transition(directory, journal, phase, changes = {}) {
@@ -246,10 +249,10 @@ export class ManagedUpgrader {
246
249
  same(instanceBinding(source), instanceBinding(plan.instance));
247
250
  if (!matchesPlan) throw new Error('Managed selection changed after the plan; create a new plan.');
248
251
  assertNoPendingJournal(source);
252
+ if (!plan.options.sxs) this.requireSessionRecords(source.config, plan.to);
249
253
  await this.autostart(source);
250
254
  const current = await inspectInstance(source);
251
255
  if (current.unsafe) throw new Error('Unresolved transaction blocks upgrade.');
252
- const sessions = sessionState(source.config);
253
256
  const target = await this.stage(plan, operation);
254
257
  verifySelection(target);
255
258
  const reloaded = loadInstance(source.directory, { verifyFiles: false });
@@ -259,11 +262,15 @@ export class ManagedUpgrader {
259
262
  if (plan.options.sxs) {
260
263
  return await this.startSxs(source, plan, target, operation);
261
264
  }
265
+ // The records can grow while the target downloads, so their size is checked again.
266
+ this.requireSessionRecords(source.config, target.version);
267
+ // Session records are read only after the old bridge stops; while it runs, every session
268
+ // start and end rewrites them.
262
269
  journal = {
263
270
  protocol: 1, operation, instanceId: source.id, previous: source.active, target,
264
271
  binding: instanceBinding(source),
265
272
  authority: current.authority, originalInstanceId: current.instanceId,
266
- sessionState: sessions, launchToken: randomUUID(), phase: 'prepared',
273
+ sessionState: null, launchToken: randomUUID(), phase: 'prepared',
267
274
  };
268
275
  const backup = join(source.directory, 'staging', operation, 'servers.backup.json');
269
276
  new PoolingFiles(source.config).stage(backup, source.config, current.authority, readFileSync(source.config));
@@ -271,13 +278,12 @@ export class ManagedUpgrader {
271
278
  await this.beforeInterruption(source, journal);
272
279
  this.transition(destination, journal, 'prepared');
273
280
  this.transition(destination, journal, 'quiescing');
274
- const drained = await inspectInstance(source, 'quiesce');
275
- if (!same(drained.authority, journal.authority)) throw new Error('Configuration changed while staging; retry from a new plan.');
281
+ const drained = await this.quiesce(source, destination, journal);
276
282
  verifyRollbackState(source, journal);
277
283
  await this.beforeStop(source, journal);
278
284
  this.transition(destination, journal, 'stopping', { quiescedInstanceId: drained.instanceId });
279
285
  const stoppedProof = await stopInstance(source, drained.instanceId);
280
- this.transition(destination, journal, 'stopped', { stoppedProof });
286
+ this.transition(destination, journal, 'stopped', { stoppedProof, sessionState: this.sessionRecords(source.config) });
281
287
  return await this.activate(destination, journal);
282
288
  } catch (error) {
283
289
  if (journal) {
@@ -294,6 +300,89 @@ export class ManagedUpgrader {
294
300
  }
295
301
  }
296
302
 
303
+ // A definite refusal releases the hold at once, as recovery would. Only an uncertain
304
+ // outcome, such as a lost reply, keeps admission held for explicit recovery.
305
+ async quiesce(source, destination, journal) {
306
+ try {
307
+ const drained = await inspectInstance(source, 'quiesce');
308
+ if (!same(drained.authority, journal.authority)) {
309
+ throw Object.assign(new Error('Configuration changed while staging; retry from a new plan.'), { refused: true });
310
+ }
311
+ return drained;
312
+ } catch (error) {
313
+ if (!error.refused) throw error;
314
+ await this.resumeRefused(source, error);
315
+ await this.releaseRefused(source, journal, error);
316
+ this.transition(destination, journal, 'aborted', { error: error.message });
317
+ throw new Error(`${error.message} Nothing was stopped; the current version is serving again.`, { cause: error });
318
+ }
319
+ }
320
+
321
+ // Config work that blocked the drain can still be settling, and resume refuses until it has,
322
+ // so resume is retried briefly. If it still refuses, admission stays held for recovery.
323
+ async resumeRefused(source, refusal) {
324
+ const deadline = Date.now() + 10000;
325
+ for (;;) {
326
+ try { return await inspectInstance(source, 'resume'); }
327
+ catch (error) {
328
+ if (!error.refused ||
329
+ Date.now() >= deadline) {
330
+ const state = error.refused ? 'stays paused' : 'may still be paused';
331
+ throw new Error(`${refusal.message} The current version ${state} until \`mcp-pacemaker upgrade --recover --instance ${source.directory} --yes\` succeeds.`, { cause: error });
332
+ }
333
+ await new Promise(resolveWait => setTimeout(resolveWait, 500));
334
+ }
335
+ }
336
+ }
337
+
338
+ // Whatever the caller held for the upgrade, such as an account's task, is released before the
339
+ // journal settles. If that fails, the journal stays at `quiescing` and recovery finishes it.
340
+ async releaseRefused(source, journal, refusal) {
341
+ try { await this.afterRefusal(source, journal); }
342
+ catch (error) {
343
+ throw new Error(`${refusal.message} Nothing was stopped and the current version is serving again. ${error.message} Run recovery to finish.`,
344
+ { cause: new AggregateError([refusal, error], refusal.message) });
345
+ }
346
+ }
347
+
348
+ // Targets before 2.1.1 compare session records when they start, reading at most 1 MiB of them.
349
+ comparesSessionRecords(version) {
350
+ const ignoresRecords = semver.valid(version) &&
351
+ semver.gte(version, '2.1.1');
352
+ return !ignoresRecords;
353
+ }
354
+
355
+ // Refuse such a target before anything is downloaded or stopped instead of restarting into a
356
+ // rollback. Only the size is checked, so a session starting or ending meanwhile cannot make
357
+ // this refuse.
358
+ requireSessionRecords(config, version) {
359
+ if (!this.comparesSessionRecords(version)) return;
360
+ const path = join(dirname(config), 'sessions.json');
361
+ const size = statSync(path, { throwIfNoEntry: false })?.size ?? 0;
362
+ if (size > MAX_CONFIG_BYTES) {
363
+ throw new Error(`Session records in ${path} are over 1 MiB, more than ${version} can compare when it starts. Upgrade to 2.1.1 or newer instead. Nothing was stopped.`);
364
+ }
365
+ }
366
+
367
+ // Records that could not be read after the stop would make such a target refuse admission,
368
+ // where rollback is no longer automatic. Refusing here, while the journal is still at
369
+ // `stopped`, rolls back to the previous version.
370
+ requireReadSessionRecords(config, journal) {
371
+ const unread = this.comparesSessionRecords(journal.target.version) &&
372
+ journal.sessionState === null &&
373
+ existsSync(join(dirname(config), 'sessions.json'));
374
+ if (unread) {
375
+ throw new Error(`Session records could not be read after the old version stopped, and ${journal.target.version} compares them when it starts.`);
376
+ }
377
+ }
378
+
379
+ // Read once the old bridge has stopped. A file the integrity helper refuses, such as one
380
+ // over 1 MiB, must not strand an upgrade whose old version is already down.
381
+ sessionRecords(config) {
382
+ try { return sessionState(config); }
383
+ catch { return null; }
384
+ }
385
+
297
386
  async startSxs(source, plan, target, operation) {
298
387
  const current = await inspectInstance(source);
299
388
  if (current.unsafe ||
@@ -340,6 +429,7 @@ export class ManagedUpgrader {
340
429
 
341
430
  async activate(directory, journal) {
342
431
  const instance = loadInstance(directory, { verifyFiles: false });
432
+ this.requireReadSessionRecords(instance.config, journal);
343
433
  verifyRollbackState(instance, journal);
344
434
  verifySelection(journal.target);
345
435
  this.transition(directory, journal, 'selecting');
@@ -479,7 +569,7 @@ export class ManagedUpgrader {
479
569
  if (journal.phase === 'stopping') {
480
570
  await this.beforeStop(instance, journal);
481
571
  const stoppedProof = await stopInstance(instance, journal.quiescedInstanceId);
482
- this.transition(instance.directory, journal, 'stopped', { stoppedProof });
572
+ this.transition(instance.directory, journal, 'stopped', { stoppedProof, sessionState: this.sessionRecords(instance.config) });
483
573
  }
484
574
  if (['stopped', 'selecting', 'selected', 'launching',
485
575
  'rollback-selecting', 'rollback-selected', 'rollback-launching'].includes(journal.phase)) {