mcp-pacemaker 2.0.2 → 2.1.0

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 (80) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/CONTRIBUTING.md +39 -13
  3. package/README.md +255 -8
  4. package/THIRD_PARTY_NOTICES.txt +1 -28
  5. package/autostart/windows/account-task.ps1 +167 -0
  6. package/autostart/windows/inspect-task.ps1 +42 -0
  7. package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
  8. package/autostart/windows/legacy-task.ps1 +301 -0
  9. package/autostart/windows/register-task.ps1 +11 -2
  10. package/bin/account-task-adapter.mjs +47 -0
  11. package/bin/account-upgrade-controller.mjs +198 -0
  12. package/bin/account-upgrade-worker.mjs +134 -0
  13. package/bin/account-worker-session.mjs +215 -0
  14. package/bin/cli-dispatch.mjs +154 -0
  15. package/bin/cli-main.mjs +1007 -0
  16. package/bin/cli-selection.mjs +43 -0
  17. package/bin/cli.mjs +7 -876
  18. package/bin/legacy-1.3.json +32 -0
  19. package/bin/legacy-installation.mjs +217 -0
  20. package/bin/legacy-task.mjs +52 -0
  21. package/bin/legacy-upgrade.mjs +471 -0
  22. package/bin/managed-runtime.mjs +170 -0
  23. package/bin/managed-state.mjs +257 -0
  24. package/bin/managed-upgrade.mjs +491 -0
  25. package/bin/mcp-bridge.mjs +132 -4
  26. package/bin/pooling-batches.mjs +13 -0
  27. package/bin/pooling-writer.mjs +4 -0
  28. package/bin/prewarm.mjs +33 -21
  29. package/bin/runtime-context.mjs +100 -0
  30. package/bin/service-control.mjs +27 -4
  31. package/bin/stable-launcher.mjs +54 -0
  32. package/bin/task-only-controller.mjs +226 -0
  33. package/bin/task-scope.mjs +65 -0
  34. package/bin/task-transaction-protocol.mjs +173 -0
  35. package/bin/top.mjs +22 -10
  36. package/bin/update-check.mjs +13 -2
  37. package/bin/upgrade-autostart.mjs +47 -0
  38. package/bin/upgrade-capability.json +8 -0
  39. package/bin/upgrade-dependencies.mjs +122 -0
  40. package/bin/upgrade-package.mjs +203 -0
  41. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  42. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  43. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  44. package/bin/windows-legacy/src/LegacyNative.cs +783 -0
  45. package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
  46. package/bin/windows-legacy-process.mjs +241 -0
  47. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  48. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  49. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  50. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  51. package/bin/windows-process-lifetime.mjs +218 -0
  52. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  53. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  54. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  55. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  56. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  57. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  58. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  59. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  60. package/bin/windows-task-channel.mjs +335 -0
  61. package/docs/account-upgrades.md +205 -0
  62. package/docs/execution-context.md +113 -0
  63. package/package.json +12 -5
  64. package/supervisor/supervise.mjs +226 -40
  65. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  66. package/tools/windows-legacy/README.md +225 -0
  67. package/tools/windows-legacy/build.ps1 +122 -0
  68. package/tools/windows-legacy/inventory.mjs +90 -0
  69. package/tools/windows-process-lifetime/README.md +119 -0
  70. package/tools/windows-process-lifetime/build.ps1 +122 -0
  71. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  72. package/tools/windows-task-channel/README.md +329 -0
  73. package/tools/windows-task-channel/build.ps1 +107 -0
  74. package/tools/windows-task-channel/inventory.mjs +117 -0
  75. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  76. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  77. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  78. package/ui/dist/index.html +2 -2
  79. package/ui/dist/third-party-manifest.json +37 -25
  80. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
@@ -0,0 +1,471 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import {
7
+ acquireInstanceLock, createInstance, directoryIdentity, durableJson,
8
+ readInstanceState, readManagedJson, readRecoveryContext, snapshotPackage, verifySelection,
9
+ legacyMigrationPhases,
10
+ } from './managed-state.mjs';
11
+ import { inspectInstance, listenerOpen, startInstance, stopInstance } from './managed-runtime.mjs';
12
+ import { PoolingFiles } from './pooling-files.mjs';
13
+ import { registerDefaultCli } from './cli-dispatch.mjs';
14
+ import { servicePaths } from './service-control.mjs';
15
+ import { WindowsLegacyTaskAdapter } from './legacy-task.mjs';
16
+ import { requireUserRuntimeContext } from './runtime-context.mjs';
17
+ import {
18
+ legacyPlanDigest, parseLegacyRegistration, requireLegacyApproval, verifyLegacyConfig, verifyLegacyListener, verifyLegacyPackage,
19
+ } from './legacy-installation.mjs';
20
+
21
+ export const legacyPhases = legacyMigrationPhases;
22
+
23
+ // How long rollback watches for the restarted legacy listener before declaring the outcome
24
+ // uncertain. Task Scheduler launch plus bridge startup dominates it on slow or loaded hosts.
25
+ const RESTART_OBSERVATION_MS = 15000;
26
+
27
+ export function legacyDormant(instance) {
28
+ const path = join(instance.directory, 'journal.json');
29
+ if (!existsSync(path)) return false;
30
+ const { journal } = readRecoveryContext(instance.directory);
31
+ if (journal.legacy?.protocol !== 1 ||
32
+ !['legacy-rolled-back', 'legacy-aborted'].includes(journal.phase)) return false;
33
+ const validator = new LegacyUpgrader();
34
+ validator.validateJournal(instance, journal);
35
+ validator.assertRestoredTask(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
36
+ const source = journal.phase === 'legacy-rolled-back' ? journal.restoredLegacyProcesses : journal.legacy.processes;
37
+ if (source?.protocol !== 1 ||
38
+ source.kind !== 'legacy-observed-process-set' ||
39
+ source.root !== journal.legacy.root ||
40
+ source.port !== instance.port ||
41
+ source.ownerSid !== journal.legacy.registration.userSid ||
42
+ source.treeCompleteness !== 'unproven' ||
43
+ source.roots?.bridge?.ownerSid !== source.ownerSid ||
44
+ source.roots?.supervisor?.ownerSid !== source.ownerSid) {
45
+ throw new Error('Dormant legacy source binding is invalid.');
46
+ }
47
+ if (journal.phase === 'legacy-rolled-back') validator.assertPartialStop(journal.legacyStop);
48
+ else if (journal.legacyStop ||
49
+ journal.targetStop) throw new Error('Aborted preparation contains conflicting shutdown evidence.');
50
+ return true;
51
+ }
52
+
53
+ export class LegacyUpgrader {
54
+ constructor({ stage, task = new WindowsLegacyTaskAdapter(), processes, checkpoint = () => {} } = {}) {
55
+ this.stage = stage;
56
+ this.task = task;
57
+ this.processes = processes;
58
+ this.checkpoint = checkpoint;
59
+ }
60
+
61
+ async processApi() {
62
+ return this.processes ?? await import('./windows-legacy-process.mjs');
63
+ }
64
+
65
+ assertRestoredTask(port, actual, original) {
66
+ if (typeof this.task.assertRestored === 'function') {
67
+ this.task.assertRestored(port, actual, original);
68
+ return;
69
+ }
70
+ if (!isDeepStrictEqual(actual, original)) throw new Error('Restored legacy registration is not the original identity.');
71
+ }
72
+
73
+ publishPrepared(instance, destination, journal) {
74
+ destination = resolve(destination);
75
+ const parent = dirname(destination);
76
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
77
+ directoryIdentity(parent);
78
+ if (existsSync(destination)) throw new Error('Managed destination exists; no candidate was overwritten.');
79
+ durableJson(join(instance.directory, 'journal.json'), journal);
80
+ const launcher = join(destination, 'stable-launcher.mjs');
81
+ if (/["\r\n]/.test(instance.node + launcher)) throw new Error('Unsupported Windows launcher path.');
82
+ writeFileSync(join(instance.directory, 'launcher.vbs'),
83
+ `Set shell = CreateObject("WScript.Shell")\r\nshell.Run """${instance.node}"" ""${launcher}""", 0, False\r\n`);
84
+ durableJson(join(instance.directory, 'instance.json'), {
85
+ ...readManagedJson(join(instance.directory, 'instance.json')), directory: destination,
86
+ });
87
+ renameSync(instance.directory, destination);
88
+ const published = readInstanceState(destination);
89
+ this.validateJournal(published, readRecoveryContext(destination).journal);
90
+ return published;
91
+ }
92
+
93
+ transition(instance, journal, phase, changes = {}) {
94
+ const current = readInstanceState(instance.directory);
95
+ if (current.id !== journal.instanceId ||
96
+ current.directoryIdentity !== instance.directoryIdentity ||
97
+ current.active.root !== journal.target.root) throw new Error('Legacy migration instance identity changed.');
98
+ Object.assign(journal, changes, { phase });
99
+ durableJson(join(instance.directory, 'journal.json'), journal);
100
+ this.checkpoint(phase, journal);
101
+ }
102
+
103
+ async taskMatches(instance, expected) {
104
+ if (this.task.verifyBinding) return await this.task.verifyBinding(instance, expected);
105
+ const actual = await this.task.inspect(instance.port);
106
+ if (actual.length !== 1 ||
107
+ !isDeepStrictEqual(actual[0], expected)) throw new Error('Selected legacy registration changed. Its task hold was not released.');
108
+ return actual[0];
109
+ }
110
+
111
+ verifySource(instance, legacy, { cliPath } = {}) {
112
+ if (verifyLegacyPackage(legacy.root, { cliPath }) !== legacy.rootIdentity) throw new Error('Legacy root directory identity changed.');
113
+ verifyLegacyConfig(instance.config, legacy);
114
+ new PoolingFiles(instance.config).verify(join(dirname(instance.config), 'state.json'), legacy.stateAuthority);
115
+ }
116
+
117
+ validateJournal(instance, journal) {
118
+ const legacy = journal.legacy;
119
+ const operationValid = typeof journal.operation === 'string' &&
120
+ /^[a-f0-9-]{36}$/.test(journal.operation);
121
+ if (!operationValid ||
122
+ !legacy ||
123
+ legacy.protocol !== 1 ||
124
+ legacy.acknowledged !== 'restart-with-uncertain-http-and-partial-tree-v1' ||
125
+ !legacy.processes ||
126
+ legacy.processes.treeCompleteness !== 'unproven' ||
127
+ legacy.processes.root !== legacy.root ||
128
+ legacy.processes.port !== instance.port ||
129
+ legacy.processes.ownerSid !== legacy.registration?.userSid ||
130
+ legacy.rootIdentity !== directoryIdentity(legacy.root) ||
131
+ legacy.backendInstanceId !== journal.originalInstanceId ||
132
+ !isDeepStrictEqual(journal.authority, legacy.authority) ||
133
+ !isDeepStrictEqual(journal.sessionState, legacy.sessions) ||
134
+ !legacy.cli ||
135
+ legacy.cli.path !== join(legacy.root, 'bin', 'cli.mjs') ||
136
+ legacy.cli.saved !== join(legacy.root, 'bin', `cli.mjs.legacy-${journal.operation}`) ||
137
+ legacy.cli.staged !== join(legacy.root, 'bin', `cli.mjs.next-${journal.operation}`) ||
138
+ journal.binding.config !== instance.config ||
139
+ journal.binding.port !== instance.port) throw new Error('Legacy recovery binding or acknowledgement is invalid.');
140
+ const registered = parseLegacyRegistration([legacy.registration], instance.port);
141
+ if (registered.root !== legacy.root) throw new Error('Legacy journal task and package roots disagree.');
142
+ if (['legacy-stopped', 'legacy-launching', 'legacy-wiring', 'legacy-admitting'].includes(journal.phase)) {
143
+ this.assertPartialStop(journal.legacyStop);
144
+ }
145
+ }
146
+
147
+ async execute(plan) {
148
+ await requireUserRuntimeContext();
149
+ const approvalDigest = legacyPlanDigest(plan);
150
+ requireLegacyApproval(plan);
151
+ this.verifySource(plan.instance, plan.legacy);
152
+ await this.taskMatches(plan.instance, plan.legacy.registration);
153
+ const operation = randomUUID();
154
+ const parent = join(dirname(plan.instance.config), 'legacy-upgrade');
155
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
156
+ let releaseWork;
157
+ try { releaseWork = acquireInstanceLock(parent); }
158
+ catch (error) {
159
+ if (error.code === 'EEXIST') {
160
+ throw new Error(`Legacy preparation lock remains at ${join(parent, 'upgrade.lock')}. Verify the original upgrader has exited, preserve its preparation files, and remove only its lock before retrying. This attempt did not change a task or process.`);
161
+ }
162
+ throw error;
163
+ }
164
+ const work = join(parent, operation);
165
+ let session;
166
+ let instance;
167
+ let journal;
168
+ let releaseInstance;
169
+ let published = false;
170
+ try {
171
+ mkdirSync(work, { mode: 0o700 });
172
+ this.verifySource(plan.instance, plan.legacy);
173
+ await this.taskMatches(plan.instance, plan.legacy.registration);
174
+ const target = await this.stage({ ...plan, instance: { ...plan.instance, directory: work } }, operation);
175
+ const capability = readManagedJson(join(target.root, 'bin', 'upgrade-capability.json'));
176
+ if (capability.legacyRestartProtocol !== 1) throw new Error('Target does not support legacy restart recovery; old service was not touched.');
177
+ const recovery = snapshotPackage(target.root, join(work, 'recovery'));
178
+ verifySelection(recovery);
179
+ this.verifySource(plan.instance, plan.legacy);
180
+ await this.taskMatches(plan.instance, plan.legacy.registration);
181
+ const api = await this.processApi();
182
+ session = await api.prepareLegacyProcesses({
183
+ port: plan.instance.port, root: plan.legacy.root, expected: plan.legacy.processes,
184
+ });
185
+ await verifyLegacyListener(plan.instance, plan.legacy);
186
+ if (legacyPlanDigest(plan) !== approvalDigest) throw new Error('Legacy plan changed after acknowledgement.');
187
+ const pointer = join(dirname(plan.instance.config), 'cli-instance.json');
188
+ if (existsSync(pointer)) throw new Error('A default managed CLI selection already exists; legacy migration refused.');
189
+ instance = createInstance({
190
+ ...plan.instance, directory: join(work, 'candidate'), root: target.root, selection: target,
191
+ });
192
+ await this.checkpoint('legacy-candidate-created', { instance, operation, work });
193
+ for (const file of ['cli-dispatch.mjs', 'task-scope.mjs', 'task-transaction-protocol.mjs']) {
194
+ copyFileSync(new URL(`./${file}`, import.meta.url), join(instance.directory, file));
195
+ }
196
+ const configBackup = join(work, 'servers.backup.json');
197
+ new PoolingFiles(instance.config).stage(configBackup, instance.config, plan.legacy.authority, readFileSync(instance.config));
198
+ const cliPath = join(plan.legacy.root, 'bin', 'cli.mjs');
199
+ const cliFiles = new PoolingFiles(cliPath);
200
+ const original = cliFiles.inspect(cliPath);
201
+ const backup = join(work, 'cli.backup.mjs');
202
+ const backupAuthority = cliFiles.stage(backup, cliPath, original, readFileSync(cliPath));
203
+ const taskBackup = join(work, 'legacy-task.json');
204
+ new PoolingFiles(instance.config).stage(taskBackup, instance.config, plan.legacy.authority,
205
+ Buffer.from(JSON.stringify({
206
+ name: plan.legacy.registration.name, path: plan.legacy.registration.path,
207
+ xml: plan.legacy.registration.xml, security: plan.legacy.registration.security,
208
+ })));
209
+ const staged = join(plan.legacy.root, 'bin', `cli.mjs.next-${operation}`);
210
+ const shim = Buffer.from([
211
+ '#!/usr/bin/env node',
212
+ `import { dispatchCli } from ${JSON.stringify(pathToFileURL(join(plan.instance.directory, 'cli-dispatch.mjs')).href)};`,
213
+ `try { await dispatchCli(${JSON.stringify(recovery.root)}); }`,
214
+ 'catch (error) { console.error(error.message); process.exitCode = 1; }',
215
+ '',
216
+ ].join('\n'));
217
+ const stagedAuthority = cliFiles.stage(staged, cliPath, original, shim);
218
+ journal = {
219
+ protocol: 1, operation, instanceId: instance.id, previous: null, target, recovery,
220
+ binding: { config: instance.config, cwd: instance.cwd, node: instance.node, port: instance.port },
221
+ authority: plan.legacy.authority, sessionState: plan.legacy.sessions,
222
+ originalInstanceId: plan.legacy.backendInstanceId,
223
+ phase: 'legacy-prepared', launchToken: randomUUID(), configBackup,
224
+ legacy: {
225
+ ...plan.legacy, approvalDigest, acknowledged: 'restart-with-uncertain-http-and-partial-tree-v1',
226
+ taskCurrent: plan.legacy.registration, taskBackup,
227
+ cli: { path: cliPath, original, backup, backupAuthority, staged, stagedAuthority,
228
+ saved: join(plan.legacy.root, 'bin', `cli.mjs.legacy-${operation}`) },
229
+ },
230
+ };
231
+ instance = this.publishPrepared(instance, plan.instance.directory, journal);
232
+ published = true;
233
+ releaseInstance = acquireInstanceLock(instance.directory);
234
+ this.transition(instance, journal, 'legacy-prepared');
235
+ this.verifySource(instance, journal.legacy);
236
+ await verifyLegacyListener(instance, journal.legacy);
237
+ this.transition(instance, journal, 'legacy-holding');
238
+ journal.legacy.taskCurrent = await this.task.hold(instance.port, journal.legacy.taskCurrent);
239
+ if (journal.legacy.taskCurrent.enabled &&
240
+ this.task.holdKind !== 'automatic-triggers') throw new Error('Selected legacy registration did not remain held.');
241
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
242
+ this.transition(instance, journal, 'legacy-held');
243
+ this.verifySource(instance, journal.legacy);
244
+ await verifyLegacyListener(instance, journal.legacy);
245
+ this.transition(instance, journal, 'legacy-stopping');
246
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
247
+ const legacyStop = await session.stop();
248
+ this.transition(instance, journal, 'legacy-stopping', { legacyStop });
249
+ this.assertPartialStop(legacyStop);
250
+ this.transition(instance, journal, 'legacy-stopped', { legacyStop });
251
+ return await this.activate(instance, journal);
252
+ } catch (error) {
253
+ if (journal &&
254
+ published) {
255
+ this.transition(instance, journal, journal.phase, { error: error.message });
256
+ if (['legacy-stopped', 'legacy-launching', 'legacy-wiring'].includes(journal.phase)) {
257
+ if (error.cleanupUnverified) throw new Error('Target shutdown is unverified; task hold and legacy recovery journal were retained.', { cause: error });
258
+ await this.rollback(instance, journal, { startupProof: error.startupProof });
259
+ }
260
+ }
261
+ throw error;
262
+ } finally {
263
+ try { await session?.close(); }
264
+ finally {
265
+ try { releaseInstance?.(); }
266
+ finally { releaseWork(); }
267
+ }
268
+ }
269
+ }
270
+
271
+ assertPartialStop(receipt) {
272
+ if (receipt?.legacyRootStopVerified !== true ||
273
+ receipt.observedDescendantsStopped !== true ||
274
+ receipt.treeCompleteness !== 'unproven' ||
275
+ !Array.isArray(receipt.errors) ||
276
+ receipt.errors.length ||
277
+ 'stopped' in receipt ||
278
+ 'activeProcesses' in receipt) throw new Error('Legacy root/observed-set shutdown is incomplete. Registration remains held; no activation was authorized.');
279
+ }
280
+
281
+ async verifyOldStopped(instance, journal) {
282
+ this.assertPartialStop(journal.legacyStop);
283
+ const api = await this.processApi();
284
+ this.assertPartialStop(await api.verifyLegacyProcessesGone(journal.legacy.processes));
285
+ if (await listenerOpen(instance.port)) throw new Error('Selected port has a listener after legacy stop; it was not touched.');
286
+ }
287
+
288
+ async activate(instance, journal) {
289
+ await this.verifyOldStopped(instance, journal);
290
+ this.verifySource(instance, journal.legacy);
291
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
292
+ verifySelection(journal.target);
293
+ this.transition(instance, journal, 'legacy-launching');
294
+ const started = await startInstance(instance.directory, { pending: true, token: journal.launchToken });
295
+ const ready = await inspectInstance(started.instance);
296
+ if (!ready.held ||
297
+ ready.traffic ||
298
+ ready.instanceId === journal.originalInstanceId ||
299
+ !isDeepStrictEqual(ready.authority, journal.authority)) throw new Error('New managed backend is not safely held with the expected config.');
300
+ verifyLegacyConfig(instance.config, journal.legacy);
301
+ this.transition(instance, journal, 'legacy-wiring', { targetInstanceId: ready.instanceId });
302
+ journal.legacy.taskCurrent = await this.task.repoint(instance.port, journal.legacy.taskCurrent,
303
+ join(instance.directory, 'launcher.vbs'));
304
+ this.transition(instance, journal, 'legacy-wiring');
305
+ this.selectCli(instance, journal);
306
+ journal.legacy.taskCurrent = await this.task.enable(instance.port, journal.legacy.taskCurrent);
307
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
308
+ const records = await this.task.stableRecords(instance.port);
309
+ durableJson(join(instance.directory, 'instance.json'), {
310
+ ...readManagedJson(join(instance.directory, 'instance.json')),
311
+ autostart: records.length ? { kind: 'stable', records } : { kind: 'none' },
312
+ });
313
+ verifyLegacyConfig(instance.config, journal.legacy);
314
+ this.transition(instance, journal, 'legacy-admitting');
315
+ await inspectInstance(started.instance, 'activate', journal);
316
+ this.transition(instance, journal, 'committed');
317
+ return {
318
+ status: 'upgraded', directory: instance.directory, port: instance.port, version: journal.target.version,
319
+ legacyStop: journal.legacyStop,
320
+ warning: 'Legacy HTTP outcomes were not certified or replayed. Historical orphan coverage is unproven; unattributed processes were left untouched.',
321
+ };
322
+ }
323
+
324
+ selectCli(instance, journal) {
325
+ const cli = journal.legacy.cli;
326
+ const files = new PoolingFiles(cli.path);
327
+ files.verify(cli.path, cli.original);
328
+ files.verify(cli.staged, cli.stagedAuthority);
329
+ files.move(cli.path, cli.saved, cli.original);
330
+ this.checkpoint('legacy-cli-original-moved', journal);
331
+ files.move(cli.staged, cli.path, cli.stagedAuthority);
332
+ this.checkpoint('legacy-cli-selected', journal);
333
+ registerDefaultCli(dirname(instance.config), instance);
334
+ const pointer = join(dirname(instance.config), 'cli-instance.json');
335
+ const selected = readManagedJson(pointer);
336
+ if (selected.id !== instance.id ||
337
+ selected.directory !== instance.directory) throw new Error('Default CLI selection belongs to another instance.');
338
+ journal.legacy.pointerAuthority = new PoolingFiles(instance.config).inspect(pointer);
339
+ this.transition(instance, journal, 'legacy-wiring');
340
+ }
341
+
342
+ restoreCli(instance, journal) {
343
+ const cli = journal.legacy.cli;
344
+ const files = new PoolingFiles(cli.path);
345
+ files.verify(cli.backup, cli.backupAuthority);
346
+ if (existsSync(cli.saved)) {
347
+ files.verify(cli.saved, cli.original);
348
+ if (existsSync(cli.path)) {
349
+ files.verify(cli.path, cli.stagedAuthority);
350
+ files.move(cli.path, cli.staged, cli.stagedAuthority);
351
+ }
352
+ files.move(cli.saved, cli.path, cli.original);
353
+ } else files.verify(cli.path, cli.original);
354
+ const pointer = join(dirname(instance.config), 'cli-instance.json');
355
+ if (existsSync(pointer)) {
356
+ const selected = readManagedJson(pointer);
357
+ if (selected.id !== instance.id ||
358
+ selected.directory !== instance.directory ||
359
+ !journal.legacy.pointerAuthority) throw new Error('Default CLI selection changed or its write outcome is uncertain.');
360
+ new PoolingFiles(instance.config).remove(pointer, journal.legacy.pointerAuthority);
361
+ }
362
+ }
363
+
364
+ async stopTarget(instance, journal, startupProof) {
365
+ if (journal.phase === 'legacy-stopped') return;
366
+ if (startupProof) {
367
+ const record = readManagedJson(servicePaths(instance.config, instance.port).record);
368
+ if (record.id !== startupProof.supervisorId ||
369
+ record.root !== journal.target.root ||
370
+ record.config !== instance.config ||
371
+ record.port !== instance.port ||
372
+ record.managedInstance !== instance.directory ||
373
+ startupProof.launchToken !== journal.launchToken) throw new Error('Target startup-stop proof does not match this migration.');
374
+ if (await listenerOpen(instance.port)) throw new Error('Port is occupied after target startup shutdown.');
375
+ journal.targetStop = { kind: 'modern-startup', receipt: startupProof };
376
+ return;
377
+ }
378
+ if (await listenerOpen(instance.port)) {
379
+ const ready = await inspectInstance(instance);
380
+ if (!ready.held ||
381
+ ready.traffic) throw new Error('Target may have admitted work; automatic legacy rollback refused.');
382
+ }
383
+ // This requires the exact new managed service record and its shutdown proof.
384
+ // A closed port cannot establish that an interrupted launch has finished.
385
+ journal.targetStop = { kind: 'modern-managed', receipt: await stopInstance(instance) };
386
+ }
387
+
388
+ async rollback(instance, journal, { startupProof } = {}) {
389
+ verifyLegacyConfig(instance.config, journal.legacy);
390
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
391
+ const phase = journal.phase;
392
+ await this.stopTarget(instance, journal, startupProof);
393
+ this.transition(instance, journal, 'legacy-rollback-stopping', { rollbackFrom: phase });
394
+ await this.verifyOldStopped(instance, journal);
395
+ const cli = journal.legacy.cli;
396
+ this.verifySource(instance, journal.legacy, { cliPath: existsSync(cli.saved) ? cli.saved : cli.path });
397
+ if (journal.legacy.taskCurrent.enabled) {
398
+ journal.legacy.taskCurrent = await this.task.hold(instance.port, journal.legacy.taskCurrent);
399
+ this.transition(instance, journal, 'legacy-rollback-stopping');
400
+ }
401
+ this.transition(instance, journal, 'legacy-cli-restoring');
402
+ this.restoreCli(instance, journal);
403
+ this.verifySource(instance, journal.legacy);
404
+ this.transition(instance, journal, 'legacy-task-restoring');
405
+ journal.legacy.taskCurrent = await this.task.restore(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
406
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
407
+ this.assertRestoredTask(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
408
+ this.transition(instance, journal, 'legacy-rollback-launching');
409
+ await this.task.start(instance.port, journal.legacy.taskCurrent);
410
+ const deadline = Date.now() + RESTART_OBSERVATION_MS;
411
+ let lastError;
412
+ while (Date.now() < deadline) {
413
+ try {
414
+ const api = await this.processApi();
415
+ const old = await api.prepareLegacyProcesses({ port: instance.port, root: journal.legacy.root });
416
+ try {
417
+ const listener = await verifyLegacyListener(instance, journal.legacy, { sameGeneration: false });
418
+ if (listener.status.instanceId === journal.originalInstanceId) throw new Error('Original legacy generation unexpectedly survived.');
419
+ this.transition(instance, journal, 'legacy-rolled-back', { restoredLegacyProcesses: old.plan });
420
+ return { status: 'rolled-back', directory: instance.directory, port: instance.port, version: '1.3.0',
421
+ treeCompleteness: 'unproven', warning: 'Legacy restarted; uncertain calls were not replayed.' };
422
+ } finally { await old.close(); }
423
+ } catch (error) { lastError = error; }
424
+ await new Promise(resolveWait => setTimeout(resolveWait, 100));
425
+ }
426
+ 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}` : ''),
428
+ { cause: lastError });
429
+ }
430
+
431
+ async recover(directory) {
432
+ await requireUserRuntimeContext();
433
+ const { instance, journal } = readRecoveryContext(directory);
434
+ this.validateJournal(instance, journal);
435
+ verifySelection(journal.recovery);
436
+ if (['legacy-rolled-back', 'legacy-aborted'].includes(journal.phase)) {
437
+ return { status: journal.phase, directory, port: instance.port };
438
+ }
439
+ const accountHolding = journal.phase === 'legacy-holding' &&
440
+ this.task.holdKind === 'automatic-triggers' &&
441
+ typeof this.task.verifyBinding === 'function';
442
+ if (['legacy-prepared', 'legacy-held'].includes(journal.phase) ||
443
+ accountHolding) {
444
+ this.verifySource(instance, journal.legacy);
445
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
446
+ const api = await this.processApi();
447
+ const old = await api.prepareLegacyProcesses({
448
+ port: instance.port, root: journal.legacy.root, expected: journal.legacy.processes,
449
+ });
450
+ try {
451
+ await verifyLegacyListener(instance, journal.legacy);
452
+ if (journal.phase !== 'legacy-prepared') {
453
+ journal.legacy.taskCurrent = await this.task.restore(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
454
+ this.assertRestoredTask(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
455
+ }
456
+ this.transition(instance, journal, 'legacy-aborted');
457
+ return { status: 'legacy-aborted', directory, port: instance.port };
458
+ } finally { await old.close(); }
459
+ }
460
+ if (journal.phase === 'legacy-stopping') {
461
+ const api = await this.processApi();
462
+ const legacyStop = await api.verifyLegacyProcessesGone(journal.legacy.processes);
463
+ this.assertPartialStop(legacyStop);
464
+ this.transition(instance, journal, 'legacy-stopped', { legacyStop });
465
+ }
466
+ if (['legacy-stopped', 'legacy-launching', 'legacy-wiring'].includes(journal.phase)) {
467
+ return await this.rollback(instance, journal);
468
+ }
469
+ throw new Error(`Legacy recovery at ${journal.phase} has an uncertain task, launch or admission outcome. No process was stopped or replayed; retain the selected task hold and journal.`);
470
+ }
471
+ }
@@ -0,0 +1,170 @@
1
+ import { fork } from 'node:child_process';
2
+ import { closeSync, existsSync, openSync } from 'node:fs';
3
+ import { get } from 'node:http';
4
+ import { connect } from 'node:net';
5
+ import { join } from 'node:path';
6
+ import { loadInstance, readManagedJson } from './managed-state.mjs';
7
+ import { controlManagedService, resumeService, servicePaths, stopManagedService, validateServiceRecord } from './service-control.mjs';
8
+ import { requireUserRuntimeContext } from './runtime-context.mjs';
9
+
10
+ const launches = new Map();
11
+
12
+ export function listenerOpen(port) {
13
+ return new Promise((resolveProbe, reject) => {
14
+ const socket = connect({ host: '127.0.0.1', port });
15
+ socket.once('connect', () => { socket.destroy(); resolveProbe(true); });
16
+ socket.once('error', error => {
17
+ if (error.code === 'ECONNREFUSED') resolveProbe(false);
18
+ else reject(error);
19
+ });
20
+ socket.setTimeout(2000, () => socket.destroy(new Error('Listener identity probe timed out.')));
21
+ });
22
+ }
23
+
24
+ export function backendStatus(port) {
25
+ return new Promise((resolveStatus, reject) => {
26
+ const request = get(`http://127.0.0.1:${port}/api/status`, response => {
27
+ let body = '';
28
+ response.on('data', bytes => {
29
+ body += bytes;
30
+ if (body.length > 4 * 1024 * 1024) request.destroy(new Error('Oversized backend status.'));
31
+ });
32
+ response.on('end', () => {
33
+ try {
34
+ if (response.statusCode !== 200) throw new Error('Backend not ready.');
35
+ resolveStatus(JSON.parse(body));
36
+ } catch (error) { reject(error); }
37
+ });
38
+ });
39
+ request.on('error', reject);
40
+ request.setTimeout(2000, () => request.destroy(new Error('Backend readiness timed out.')));
41
+ });
42
+ }
43
+
44
+ export function serviceSelection(instance) {
45
+ return { root: instance.active.root, config: instance.config, port: instance.port };
46
+ }
47
+
48
+ function assertInstanceService(instance) {
49
+ const record = validateServiceRecord(
50
+ readManagedJson(servicePaths(instance.config, instance.port).record), serviceSelection(instance));
51
+ if (record.managedInstance !== instance.directory) {
52
+ throw new Error('Supervisor belongs to a different managed instance; no service action was sent.');
53
+ }
54
+ }
55
+
56
+ export async function startInstance(directory, { pending = false, token } = {}) {
57
+ await requireUserRuntimeContext();
58
+ let instance = loadInstance(directory);
59
+ const journalPath = join(directory, 'journal.json');
60
+ if (existsSync(journalPath)) {
61
+ const journal = readManagedJson(journalPath);
62
+ const legacyStart = journal.phase === 'legacy-launching' &&
63
+ journal.legacy?.protocol === 1 &&
64
+ journal.legacy.acknowledged === 'restart-with-uncertain-http-and-partial-tree-v1';
65
+ const authorized = (['launching', 'rollback-launching'].includes(journal.phase) || legacyStart) &&
66
+ journal.launchToken === token &&
67
+ pending;
68
+ if (!['committed', 'rolled-back', 'aborted'].includes(journal.phase) &&
69
+ !authorized) throw new Error('Unfinished upgrade journal blocks start; use explicit recovery.');
70
+ }
71
+ if (instance.port !== 0 &&
72
+ await listenerOpen(instance.port)) throw new Error('Selected port is busy; no listener was adopted.');
73
+ if (instance.port) resumeService(instance.config, instance.port);
74
+ const log = openSync(join(directory, 'launcher.log'), 'a', 0o600);
75
+ const child = fork(join(directory, 'stable-launcher.mjs'), [], {
76
+ execPath: instance.node, execArgv: [], detached: true, windowsHide: true,
77
+ stdio: ['ignore', log, log, 'ipc'],
78
+ env: {
79
+ ...process.env, MCP_PACEMAKER_UPGRADE_PENDING: pending ? '1' : '0',
80
+ MCP_PACEMAKER_UPGRADE_TOKEN: token ?? '',
81
+ },
82
+ });
83
+ closeSync(log);
84
+ const exited = new Promise(resolveExit => child.once('exit', resolveExit));
85
+ const running = launches.get(directory) ?? [];
86
+ running.push({ child, exited });
87
+ launches.set(directory, running);
88
+ let timer;
89
+ let startupStopped = false;
90
+ let startupStopId;
91
+ child.on('message', message => {
92
+ if (message?.type === 'supervisor-stopped' &&
93
+ message.stopped === true) {
94
+ startupStopped = true;
95
+ startupStopId = message.id;
96
+ }
97
+ });
98
+ try {
99
+ const ready = await new Promise((resolveReady, reject) => {
100
+ child.once('error', reject);
101
+ child.once('exit', () => reject(new Error(`Managed startup failed. Inspect ${join(directory, 'launcher.log')}.`)));
102
+ child.on('message', message => {
103
+ if (message?.type === 'supervisor-ready') resolveReady(message);
104
+ });
105
+ timer = setTimeout(() => reject(new Error('Managed startup readiness timed out.')), 15000);
106
+ });
107
+ instance = loadInstance(directory, { verifyFiles: false });
108
+ const valid = ready.version === instance.active.version &&
109
+ ready.port === instance.port &&
110
+ ready.held === pending;
111
+ if (!valid) throw new Error('Started backend identity does not match the active selection.');
112
+ const status = await backendStatus(instance.port);
113
+ if (status.service !== 'mcp-pacemaker' ||
114
+ status.version !== ready.version ||
115
+ status.instanceId !== ready.instanceId) throw new Error('Actual listener did not verify the started backend.');
116
+ return { instance, ready };
117
+ } catch (error) {
118
+ if (child.connected) {
119
+ child.send('stop');
120
+ await new Promise((resolveExit, reject) => {
121
+ if (child.exitCode !== null) { resolveExit(); return; }
122
+ const deadline = setTimeout(() => reject(new Error('Failed startup shutdown is unverified; retain journal and package.')), 12000);
123
+ child.once('exit', () => { clearTimeout(deadline); resolveExit(); });
124
+ });
125
+ }
126
+ error.startupStopped = startupStopped;
127
+ if (startupStopped) error.startupProof = {
128
+ supervisorId: startupStopId, root: instance.active.root, config: instance.config,
129
+ port: instance.port, launchToken: token,
130
+ };
131
+ if (!startupStopped) error.cleanupUnverified = true;
132
+ throw error;
133
+ } finally {
134
+ clearTimeout(timer);
135
+ if (child.connected) child.disconnect();
136
+ child.unref();
137
+ }
138
+ }
139
+
140
+ export async function stopInstance(instance, quiescedInstanceId) {
141
+ assertInstanceService(instance);
142
+ const selection = serviceSelection(instance);
143
+ const stopped = await stopManagedService({ ...selection, quiescedInstanceId });
144
+ if (await listenerOpen(instance.port)) throw new Error('Stopped supervisor still has a live listener; recovery required.');
145
+ await waitForInstanceExit(instance.directory);
146
+ return stopped;
147
+ }
148
+
149
+ export async function waitForInstanceExit(directory) {
150
+ const running = launches.get(directory) ?? [];
151
+ let timeout;
152
+ try {
153
+ await Promise.race([
154
+ Promise.all(running.map(entry => entry.exited)),
155
+ new Promise((_, reject) => {
156
+ timeout = setTimeout(() => reject(new Error('Owned stable launcher did not exit; retain its files.')), 12000);
157
+ }),
158
+ ]);
159
+ launches.delete(directory);
160
+ } finally { clearTimeout(timeout); }
161
+ }
162
+
163
+ export async function inspectInstance(instance, action = 'inspect', expected = {}) {
164
+ assertInstanceService(instance);
165
+ const response = await controlManagedService(serviceSelection(instance), action, expected);
166
+ const status = await backendStatus(instance.port);
167
+ if (status.version !== response.version ||
168
+ status.instanceId !== response.instanceId) throw new Error('Listener and managed supervisor identity disagree.');
169
+ return response;
170
+ }