mcp-pacemaker 2.0.2 → 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 (90) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/CONTRIBUTING.md +46 -13
  3. package/README.md +109 -524
  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-access.ps1 +359 -0
  9. package/autostart/windows/legacy-task.ps1 +301 -0
  10. package/autostart/windows/register-task.ps1 +11 -2
  11. package/bin/account-task-adapter.mjs +47 -0
  12. package/bin/account-upgrade-controller.mjs +198 -0
  13. package/bin/account-upgrade-worker.mjs +141 -0
  14. package/bin/account-worker-session.mjs +215 -0
  15. package/bin/cli-dispatch.mjs +154 -0
  16. package/bin/cli-main.mjs +1063 -0
  17. package/bin/cli-selection.mjs +43 -0
  18. package/bin/cli.mjs +7 -876
  19. package/bin/dependency-lock.json +642 -0
  20. package/bin/legacy-1.3.json +32 -0
  21. package/bin/legacy-access.mjs +73 -0
  22. package/bin/legacy-installation.mjs +273 -0
  23. package/bin/legacy-task.mjs +76 -0
  24. package/bin/legacy-upgrade.mjs +527 -0
  25. package/bin/managed-runtime.mjs +170 -0
  26. package/bin/managed-state.mjs +257 -0
  27. package/bin/managed-upgrade.mjs +581 -0
  28. package/bin/mcp-bridge.mjs +150 -4
  29. package/bin/pooling-batches.mjs +13 -0
  30. package/bin/pooling-writer.mjs +4 -0
  31. package/bin/prewarm.mjs +59 -25
  32. package/bin/runtime-context.mjs +100 -0
  33. package/bin/service-control.mjs +32 -4
  34. package/bin/stable-launcher.mjs +54 -0
  35. package/bin/task-only-controller.mjs +226 -0
  36. package/bin/task-scope.mjs +65 -0
  37. package/bin/task-transaction-protocol.mjs +173 -0
  38. package/bin/top.mjs +22 -10
  39. package/bin/update-check.mjs +13 -2
  40. package/bin/upgrade-autostart.mjs +47 -0
  41. package/bin/upgrade-capability.json +9 -0
  42. package/bin/upgrade-dependencies.mjs +183 -0
  43. package/bin/upgrade-package.mjs +205 -0
  44. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  45. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  46. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  47. package/bin/windows-legacy/src/LegacyNative.cs +832 -0
  48. package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
  49. package/bin/windows-legacy-process.mjs +248 -0
  50. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  51. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  52. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  53. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  54. package/bin/windows-process-lifetime.mjs +218 -0
  55. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  56. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  57. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  58. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  59. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  60. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  61. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  62. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  63. package/bin/windows-task-channel.mjs +335 -0
  64. package/docs/account-upgrades.md +218 -0
  65. package/docs/compatibility.md +39 -0
  66. package/docs/configuration.md +146 -0
  67. package/docs/dashboards.md +125 -0
  68. package/docs/execution-context.md +119 -0
  69. package/docs/http-api.md +47 -0
  70. package/docs/install.md +52 -0
  71. package/docs/operations.md +99 -0
  72. package/docs/upgrades.md +324 -0
  73. package/package.json +19 -5
  74. package/supervisor/supervise.mjs +226 -40
  75. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  76. package/tools/windows-legacy/README.md +251 -0
  77. package/tools/windows-legacy/build.ps1 +122 -0
  78. package/tools/windows-legacy/inventory.mjs +92 -0
  79. package/tools/windows-process-lifetime/README.md +119 -0
  80. package/tools/windows-process-lifetime/build.ps1 +122 -0
  81. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  82. package/tools/windows-task-channel/README.md +329 -0
  83. package/tools/windows-task-channel/build.ps1 +107 -0
  84. package/tools/windows-task-channel/inventory.mjs +117 -0
  85. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  86. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  87. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  88. package/ui/dist/index.html +2 -2
  89. package/ui/dist/third-party-manifest.json +37 -25
  90. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
@@ -0,0 +1,527 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, 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 semver from 'semver';
7
+ import {
8
+ acquireInstanceLock, createInstance, directoryIdentity, durableJson,
9
+ readInstanceState, readManagedJson, readRecoveryContext, snapshotPackage, verifySelection,
10
+ legacyMigrationPhases,
11
+ } from './managed-state.mjs';
12
+ import { inspectInstance, listenerOpen, startInstance, stopInstance } from './managed-runtime.mjs';
13
+ import { MAX_CONFIG_BYTES, PoolingFiles } from './pooling-files.mjs';
14
+ import { registerDefaultCli } from './cli-dispatch.mjs';
15
+ import { servicePaths } from './service-control.mjs';
16
+ import { WindowsLegacyTaskAdapter } from './legacy-task.mjs';
17
+ import { requireUserRuntimeContext } from './runtime-context.mjs';
18
+ import {
19
+ legacyPlanDigest, legacySessionState, parseLegacyRegistration, requireLegacyApproval, verifyLegacyConfig, verifyLegacyListener, verifyLegacyPackage,
20
+ } from './legacy-installation.mjs';
21
+
22
+ export const legacyPhases = legacyMigrationPhases;
23
+
24
+ // How long rollback watches for the restarted legacy listener before declaring the outcome
25
+ // uncertain. Task Scheduler launch plus bridge startup dominates it on slow or loaded hosts.
26
+ const RESTART_OBSERVATION_MS = 15000;
27
+
28
+ export function legacyDormant(instance) {
29
+ const path = join(instance.directory, 'journal.json');
30
+ if (!existsSync(path)) return false;
31
+ const { journal } = readRecoveryContext(instance.directory);
32
+ if (journal.legacy?.protocol !== 1 ||
33
+ !['legacy-rolled-back', 'legacy-aborted'].includes(journal.phase)) return false;
34
+ const validator = new LegacyUpgrader();
35
+ validator.validateJournal(instance, journal);
36
+ validator.assertRestoredTask(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
37
+ const source = journal.phase === 'legacy-rolled-back' ? journal.restoredLegacyProcesses : journal.legacy.processes;
38
+ if (source?.protocol !== 1 ||
39
+ source.kind !== 'legacy-observed-process-set' ||
40
+ source.root !== journal.legacy.root ||
41
+ source.port !== instance.port ||
42
+ source.ownerSid !== journal.legacy.registration.userSid ||
43
+ source.treeCompleteness !== 'unproven' ||
44
+ source.roots?.bridge?.ownerSid !== source.ownerSid ||
45
+ source.roots?.supervisor?.ownerSid !== source.ownerSid) {
46
+ throw new Error('Dormant legacy source binding is invalid.');
47
+ }
48
+ if (journal.phase === 'legacy-rolled-back') validator.assertPartialStop(journal.legacyStop);
49
+ else if (journal.legacyStop ||
50
+ journal.targetStop) throw new Error('Aborted preparation contains conflicting shutdown evidence.');
51
+ return true;
52
+ }
53
+
54
+ export class LegacyUpgrader {
55
+ constructor({ stage, task = new WindowsLegacyTaskAdapter(), processes, checkpoint = () => {} } = {}) {
56
+ this.stage = stage;
57
+ this.task = task;
58
+ this.processes = processes;
59
+ this.checkpoint = checkpoint;
60
+ }
61
+
62
+ async processApi() {
63
+ return this.processes ?? await import('./windows-legacy-process.mjs');
64
+ }
65
+
66
+ assertRestoredTask(port, actual, original) {
67
+ if (typeof this.task.assertRestored === 'function') {
68
+ this.task.assertRestored(port, actual, original);
69
+ return;
70
+ }
71
+ if (!isDeepStrictEqual(actual, original)) throw new Error('Restored legacy registration is not the original identity.');
72
+ }
73
+
74
+ publishPrepared(instance, destination, journal) {
75
+ destination = resolve(destination);
76
+ const parent = dirname(destination);
77
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
78
+ directoryIdentity(parent);
79
+ if (existsSync(destination)) throw new Error('Managed destination exists; no candidate was overwritten.');
80
+ durableJson(join(instance.directory, 'journal.json'), journal);
81
+ const launcher = join(destination, 'stable-launcher.mjs');
82
+ if (/["\r\n]/.test(instance.node + launcher)) throw new Error('Unsupported Windows launcher path.');
83
+ writeFileSync(join(instance.directory, 'launcher.vbs'),
84
+ `Set shell = CreateObject("WScript.Shell")\r\nshell.Run """${instance.node}"" ""${launcher}""", 0, False\r\n`);
85
+ durableJson(join(instance.directory, 'instance.json'), {
86
+ ...readManagedJson(join(instance.directory, 'instance.json')), directory: destination,
87
+ });
88
+ renameSync(instance.directory, destination);
89
+ const published = readInstanceState(destination);
90
+ this.validateJournal(published, readRecoveryContext(destination).journal);
91
+ return published;
92
+ }
93
+
94
+ transition(instance, journal, phase, changes = {}) {
95
+ const current = readInstanceState(instance.directory);
96
+ if (current.id !== journal.instanceId ||
97
+ current.directoryIdentity !== instance.directoryIdentity ||
98
+ current.active.root !== journal.target.root) throw new Error('Legacy migration instance identity changed.');
99
+ Object.assign(journal, changes, { phase });
100
+ durableJson(join(instance.directory, 'journal.json'), journal);
101
+ this.checkpoint(phase, journal);
102
+ }
103
+
104
+ async taskMatches(instance, expected) {
105
+ if (this.task.verifyBinding) return await this.task.verifyBinding(instance, expected);
106
+ const actual = await this.task.inspect(instance.port);
107
+ if (actual.length !== 1 ||
108
+ !isDeepStrictEqual(actual[0], expected)) throw new Error('Selected legacy registration changed. Its task hold was not released.');
109
+ return actual[0];
110
+ }
111
+
112
+ verifySource(instance, legacy, { cliPath } = {}) {
113
+ if (verifyLegacyPackage(legacy.root, { cliPath }) !== legacy.rootIdentity) throw new Error('Legacy root directory identity changed.');
114
+ verifyLegacyConfig(instance.config, legacy);
115
+ new PoolingFiles(instance.config).verify(join(dirname(instance.config), 'state.json'), legacy.stateAuthority);
116
+ }
117
+
118
+ validateJournal(instance, journal) {
119
+ const legacy = journal.legacy;
120
+ const operationValid = typeof journal.operation === 'string' &&
121
+ /^[a-f0-9-]{36}$/.test(journal.operation);
122
+ if (!operationValid ||
123
+ !legacy ||
124
+ legacy.protocol !== 1 ||
125
+ legacy.acknowledged !== 'restart-with-uncertain-http-and-partial-tree-v1' ||
126
+ !legacy.processes ||
127
+ legacy.processes.treeCompleteness !== 'unproven' ||
128
+ legacy.processes.root !== legacy.root ||
129
+ legacy.processes.port !== instance.port ||
130
+ legacy.processes.ownerSid !== legacy.registration?.userSid ||
131
+ legacy.rootIdentity !== directoryIdentity(legacy.root) ||
132
+ legacy.backendInstanceId !== journal.originalInstanceId ||
133
+ !isDeepStrictEqual(journal.authority, legacy.authority) ||
134
+ !isDeepStrictEqual(journal.sessionState, legacy.sessions) ||
135
+ !legacy.cli ||
136
+ legacy.cli.path !== join(legacy.root, 'bin', 'cli.mjs') ||
137
+ legacy.cli.saved !== join(legacy.root, 'bin', `cli.mjs.legacy-${journal.operation}`) ||
138
+ legacy.cli.staged !== join(legacy.root, 'bin', `cli.mjs.next-${journal.operation}`) ||
139
+ journal.binding.config !== instance.config ||
140
+ journal.binding.port !== instance.port) throw new Error('Legacy recovery binding or acknowledgement is invalid.');
141
+ const registered = parseLegacyRegistration([legacy.registration], instance.port);
142
+ if (registered.root !== legacy.root) throw new Error('Legacy journal task and package roots disagree.');
143
+ if (['legacy-stopped', 'legacy-launching', 'legacy-wiring', 'legacy-admitting'].includes(journal.phase)) {
144
+ this.assertPartialStop(journal.legacyStop);
145
+ }
146
+ }
147
+
148
+ async execute(plan) {
149
+ await requireUserRuntimeContext();
150
+ const approvalDigest = legacyPlanDigest(plan);
151
+ requireLegacyApproval(plan);
152
+ this.requireSessionRecords(plan.instance.config, plan.to);
153
+ this.verifySource(plan.instance, plan.legacy);
154
+ await this.taskMatches(plan.instance, plan.legacy.registration);
155
+ const operation = randomUUID();
156
+ const parent = join(dirname(plan.instance.config), 'legacy-upgrade');
157
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
158
+ let releaseWork;
159
+ try { releaseWork = acquireInstanceLock(parent); }
160
+ catch (error) {
161
+ if (error.code === 'EEXIST') {
162
+ 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.`);
163
+ }
164
+ throw error;
165
+ }
166
+ const work = join(parent, operation);
167
+ let session;
168
+ let instance;
169
+ let journal;
170
+ let releaseInstance;
171
+ let published = false;
172
+ try {
173
+ mkdirSync(work, { mode: 0o700 });
174
+ this.verifySource(plan.instance, plan.legacy);
175
+ await this.taskMatches(plan.instance, plan.legacy.registration);
176
+ const target = await this.stage({ ...plan, instance: { ...plan.instance, directory: work } }, operation);
177
+ const capability = readManagedJson(join(target.root, 'bin', 'upgrade-capability.json'));
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);
181
+ const recovery = snapshotPackage(target.root, join(work, 'recovery'));
182
+ verifySelection(recovery);
183
+ this.verifySource(plan.instance, plan.legacy);
184
+ await this.taskMatches(plan.instance, plan.legacy.registration);
185
+ const api = await this.processApi();
186
+ session = await api.prepareLegacyProcesses({
187
+ port: plan.instance.port, root: plan.legacy.root, expected: plan.legacy.processes,
188
+ });
189
+ await verifyLegacyListener(plan.instance, plan.legacy);
190
+ if (legacyPlanDigest(plan) !== approvalDigest) throw new Error('Legacy plan changed after acknowledgement.');
191
+ const pointer = join(dirname(plan.instance.config), 'cli-instance.json');
192
+ if (existsSync(pointer)) throw new Error('A default managed CLI selection already exists; legacy migration refused.');
193
+ instance = createInstance({
194
+ ...plan.instance, directory: join(work, 'candidate'), root: target.root, selection: target,
195
+ });
196
+ await this.checkpoint('legacy-candidate-created', { instance, operation, work });
197
+ for (const file of ['cli-dispatch.mjs', 'task-scope.mjs', 'task-transaction-protocol.mjs']) {
198
+ copyFileSync(new URL(`./${file}`, import.meta.url), join(instance.directory, file));
199
+ }
200
+ const configBackup = join(work, 'servers.backup.json');
201
+ new PoolingFiles(instance.config).stage(configBackup, instance.config, plan.legacy.authority, readFileSync(instance.config));
202
+ const cliPath = join(plan.legacy.root, 'bin', 'cli.mjs');
203
+ const cliFiles = new PoolingFiles(cliPath);
204
+ const original = cliFiles.inspect(cliPath);
205
+ const backup = join(work, 'cli.backup.mjs');
206
+ const backupAuthority = cliFiles.stage(backup, cliPath, original, readFileSync(cliPath));
207
+ const taskBackup = join(work, 'legacy-task.json');
208
+ new PoolingFiles(instance.config).stage(taskBackup, instance.config, plan.legacy.authority,
209
+ Buffer.from(JSON.stringify({
210
+ name: plan.legacy.registration.name, path: plan.legacy.registration.path,
211
+ xml: plan.legacy.registration.xml, security: plan.legacy.registration.security,
212
+ })));
213
+ const staged = join(plan.legacy.root, 'bin', `cli.mjs.next-${operation}`);
214
+ const shim = Buffer.from([
215
+ '#!/usr/bin/env node',
216
+ `import { dispatchCli } from ${JSON.stringify(pathToFileURL(join(plan.instance.directory, 'cli-dispatch.mjs')).href)};`,
217
+ `try { await dispatchCli(${JSON.stringify(recovery.root)}); }`,
218
+ 'catch (error) { console.error(error.message); process.exitCode = 1; }',
219
+ '',
220
+ ].join('\n'));
221
+ const stagedAuthority = cliFiles.stage(staged, cliPath, original, shim);
222
+ journal = {
223
+ protocol: 1, operation, instanceId: instance.id, previous: null, target, recovery,
224
+ binding: { config: instance.config, cwd: instance.cwd, node: instance.node, port: instance.port },
225
+ authority: plan.legacy.authority, sessionState: plan.legacy.sessions,
226
+ originalInstanceId: plan.legacy.backendInstanceId,
227
+ phase: 'legacy-prepared', launchToken: randomUUID(), configBackup,
228
+ legacy: {
229
+ ...plan.legacy, approvalDigest, acknowledged: 'restart-with-uncertain-http-and-partial-tree-v1',
230
+ taskCurrent: plan.legacy.registration, taskBackup,
231
+ cli: { path: cliPath, original, backup, backupAuthority, staged, stagedAuthority,
232
+ saved: join(plan.legacy.root, 'bin', `cli.mjs.legacy-${operation}`) },
233
+ },
234
+ };
235
+ instance = this.publishPrepared(instance, plan.instance.directory, journal);
236
+ published = true;
237
+ releaseInstance = acquireInstanceLock(instance.directory);
238
+ this.transition(instance, journal, 'legacy-prepared');
239
+ this.verifySource(instance, journal.legacy);
240
+ await verifyLegacyListener(instance, journal.legacy);
241
+ this.transition(instance, journal, 'legacy-holding');
242
+ journal.legacy.taskCurrent = await this.task.hold(instance.port, journal.legacy.taskCurrent);
243
+ if (journal.legacy.taskCurrent.enabled &&
244
+ this.task.holdKind !== 'automatic-triggers') throw new Error('Selected legacy registration did not remain held.');
245
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
246
+ this.transition(instance, journal, 'legacy-held');
247
+ this.verifySource(instance, journal.legacy);
248
+ await verifyLegacyListener(instance, journal.legacy);
249
+ this.transition(instance, journal, 'legacy-stopping');
250
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
251
+ const legacyStop = await session.stop();
252
+ this.transition(instance, journal, 'legacy-stopping', { legacyStop });
253
+ this.assertPartialStop(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
+ });
260
+ return await this.activate(instance, journal);
261
+ } catch (error) {
262
+ if (journal &&
263
+ published) {
264
+ this.transition(instance, journal, journal.phase, { error: error.message });
265
+ if (['legacy-stopped', 'legacy-launching', 'legacy-wiring'].includes(journal.phase)) {
266
+ if (error.cleanupUnverified) throw new Error('Target shutdown is unverified; task hold and legacy recovery journal were retained.', { cause: error });
267
+ await this.rollback(instance, journal, { startupProof: error.startupProof });
268
+ }
269
+ }
270
+ throw error;
271
+ } finally {
272
+ try { await session?.close(); }
273
+ finally {
274
+ try { releaseInstance?.(); }
275
+ finally { releaseWork(); }
276
+ }
277
+ }
278
+ }
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.
320
+ assertPartialStop(receipt) {
321
+ if (receipt?.legacyRootStopVerified !== true ||
322
+ typeof receipt.observedDescendantsStopped !== 'boolean' ||
323
+ receipt.treeCompleteness !== 'unproven' ||
324
+ !Array.isArray(receipt.errors) ||
325
+ 'stopped' in receipt ||
326
+ 'activeProcesses' in receipt) throw new Error('Legacy supervisor and bridge shutdown is not verified. Registration remains held; no activation was authorized.');
327
+ }
328
+
329
+ async verifyOldStopped(instance, journal) {
330
+ this.assertPartialStop(journal.legacyStop);
331
+ const api = await this.processApi();
332
+ this.assertPartialStop(await api.verifyLegacyProcessesGone(journal.legacy.processes));
333
+ if (await listenerOpen(instance.port)) throw new Error('Selected port has a listener after legacy stop; it was not touched.');
334
+ }
335
+
336
+ async activate(instance, journal) {
337
+ this.requireReadSessionRecords(instance.config, journal);
338
+ await this.verifyOldStopped(instance, journal);
339
+ this.verifySource(instance, journal.legacy);
340
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
341
+ verifySelection(journal.target);
342
+ this.transition(instance, journal, 'legacy-launching');
343
+ const started = await startInstance(instance.directory, { pending: true, token: journal.launchToken });
344
+ const ready = await inspectInstance(started.instance);
345
+ if (!ready.held ||
346
+ ready.traffic ||
347
+ ready.instanceId === journal.originalInstanceId ||
348
+ !isDeepStrictEqual(ready.authority, journal.authority)) throw new Error('New managed backend is not safely held with the expected config.');
349
+ verifyLegacyConfig(instance.config, journal.legacy);
350
+ this.transition(instance, journal, 'legacy-wiring', { targetInstanceId: ready.instanceId });
351
+ journal.legacy.taskCurrent = await this.task.repoint(instance.port, journal.legacy.taskCurrent,
352
+ join(instance.directory, 'launcher.vbs'));
353
+ this.transition(instance, journal, 'legacy-wiring');
354
+ this.selectCli(instance, journal);
355
+ journal.legacy.taskCurrent = await this.task.enable(instance.port, journal.legacy.taskCurrent);
356
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
357
+ const records = await this.task.stableRecords(instance.port);
358
+ durableJson(join(instance.directory, 'instance.json'), {
359
+ ...readManagedJson(join(instance.directory, 'instance.json')),
360
+ autostart: records.length ? { kind: 'stable', records } : { kind: 'none' },
361
+ });
362
+ verifyLegacyConfig(instance.config, journal.legacy);
363
+ this.transition(instance, journal, 'legacy-admitting');
364
+ await inspectInstance(started.instance, 'activate', journal);
365
+ this.transition(instance, journal, 'committed');
366
+ return {
367
+ status: 'upgraded', directory: instance.directory, port: instance.port, version: journal.target.version,
368
+ legacyStop: journal.legacyStop,
369
+ warning: this.stopWarning(journal.legacyStop),
370
+ };
371
+ }
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
+
379
+ selectCli(instance, journal) {
380
+ const cli = journal.legacy.cli;
381
+ const files = new PoolingFiles(cli.path);
382
+ files.verify(cli.path, cli.original);
383
+ files.verify(cli.staged, cli.stagedAuthority);
384
+ files.move(cli.path, cli.saved, cli.original);
385
+ this.checkpoint('legacy-cli-original-moved', journal);
386
+ files.move(cli.staged, cli.path, cli.stagedAuthority);
387
+ this.checkpoint('legacy-cli-selected', journal);
388
+ registerDefaultCli(dirname(instance.config), instance);
389
+ const pointer = join(dirname(instance.config), 'cli-instance.json');
390
+ const selected = readManagedJson(pointer);
391
+ if (selected.id !== instance.id ||
392
+ selected.directory !== instance.directory) throw new Error('Default CLI selection belongs to another instance.');
393
+ journal.legacy.pointerAuthority = new PoolingFiles(instance.config).inspect(pointer);
394
+ this.transition(instance, journal, 'legacy-wiring');
395
+ }
396
+
397
+ restoreCli(instance, journal) {
398
+ const cli = journal.legacy.cli;
399
+ const files = new PoolingFiles(cli.path);
400
+ files.verify(cli.backup, cli.backupAuthority);
401
+ if (existsSync(cli.saved)) {
402
+ files.verify(cli.saved, cli.original);
403
+ if (existsSync(cli.path)) {
404
+ files.verify(cli.path, cli.stagedAuthority);
405
+ files.move(cli.path, cli.staged, cli.stagedAuthority);
406
+ }
407
+ files.move(cli.saved, cli.path, cli.original);
408
+ } else files.verify(cli.path, cli.original);
409
+ const pointer = join(dirname(instance.config), 'cli-instance.json');
410
+ if (existsSync(pointer)) {
411
+ const selected = readManagedJson(pointer);
412
+ if (selected.id !== instance.id ||
413
+ selected.directory !== instance.directory ||
414
+ !journal.legacy.pointerAuthority) throw new Error('Default CLI selection changed or its write outcome is uncertain.');
415
+ new PoolingFiles(instance.config).remove(pointer, journal.legacy.pointerAuthority);
416
+ }
417
+ }
418
+
419
+ async stopTarget(instance, journal, startupProof) {
420
+ if (journal.phase === 'legacy-stopped') return;
421
+ if (startupProof) {
422
+ const record = readManagedJson(servicePaths(instance.config, instance.port).record);
423
+ if (record.id !== startupProof.supervisorId ||
424
+ record.root !== journal.target.root ||
425
+ record.config !== instance.config ||
426
+ record.port !== instance.port ||
427
+ record.managedInstance !== instance.directory ||
428
+ startupProof.launchToken !== journal.launchToken) throw new Error('Target startup-stop proof does not match this migration.');
429
+ if (await listenerOpen(instance.port)) throw new Error('Port is occupied after target startup shutdown.');
430
+ journal.targetStop = { kind: 'modern-startup', receipt: startupProof };
431
+ return;
432
+ }
433
+ if (await listenerOpen(instance.port)) {
434
+ const ready = await inspectInstance(instance);
435
+ if (!ready.held ||
436
+ ready.traffic) throw new Error('Target may have admitted work; automatic legacy rollback refused.');
437
+ }
438
+ // This requires the exact new managed service record and its shutdown proof.
439
+ // A closed port cannot establish that an interrupted launch has finished.
440
+ journal.targetStop = { kind: 'modern-managed', receipt: await stopInstance(instance) };
441
+ }
442
+
443
+ async rollback(instance, journal, { startupProof } = {}) {
444
+ verifyLegacyConfig(instance.config, journal.legacy);
445
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
446
+ const phase = journal.phase;
447
+ await this.stopTarget(instance, journal, startupProof);
448
+ this.transition(instance, journal, 'legacy-rollback-stopping', { rollbackFrom: phase });
449
+ await this.verifyOldStopped(instance, journal);
450
+ const cli = journal.legacy.cli;
451
+ this.verifySource(instance, journal.legacy, { cliPath: existsSync(cli.saved) ? cli.saved : cli.path });
452
+ if (journal.legacy.taskCurrent.enabled) {
453
+ journal.legacy.taskCurrent = await this.task.hold(instance.port, journal.legacy.taskCurrent);
454
+ this.transition(instance, journal, 'legacy-rollback-stopping');
455
+ }
456
+ this.transition(instance, journal, 'legacy-cli-restoring');
457
+ this.restoreCli(instance, journal);
458
+ this.verifySource(instance, journal.legacy);
459
+ this.transition(instance, journal, 'legacy-task-restoring');
460
+ journal.legacy.taskCurrent = await this.task.restore(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
461
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
462
+ this.assertRestoredTask(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
463
+ this.transition(instance, journal, 'legacy-rollback-launching');
464
+ await this.task.start(instance.port, journal.legacy.taskCurrent);
465
+ const started = Date.now();
466
+ const deadline = started + RESTART_OBSERVATION_MS;
467
+ let lastError;
468
+ while (Date.now() < deadline) {
469
+ try {
470
+ const api = await this.processApi();
471
+ const old = await api.prepareLegacyProcesses({ port: instance.port, root: journal.legacy.root });
472
+ try {
473
+ const listener = await verifyLegacyListener(instance, journal.legacy, { sameGeneration: false });
474
+ if (listener.status.instanceId === journal.originalInstanceId) throw new Error('Original legacy generation unexpectedly survived.');
475
+ this.transition(instance, journal, 'legacy-rolled-back', { restoredLegacyProcesses: old.plan });
476
+ return { status: 'rolled-back', directory: instance.directory, port: instance.port, version: '1.3.0',
477
+ treeCompleteness: 'unproven', warning: 'Legacy restarted; uncertain calls were not replayed.' };
478
+ } finally { await old.close(); }
479
+ } catch (error) { lastError = error; }
480
+ await new Promise(resolveWait => setTimeout(resolveWait, 100));
481
+ }
482
+ throw new Error('Legacy restart outcome is uncertain; retained code, task and journal require inspection.' +
483
+ (lastError ? ` Last observation after ${Date.now() - started} ms: ${lastError.message}` : ''),
484
+ { cause: lastError });
485
+ }
486
+
487
+ async recover(directory) {
488
+ await requireUserRuntimeContext();
489
+ const { instance, journal } = readRecoveryContext(directory);
490
+ this.validateJournal(instance, journal);
491
+ verifySelection(journal.recovery);
492
+ if (['legacy-rolled-back', 'legacy-aborted'].includes(journal.phase)) {
493
+ return { status: journal.phase, directory, port: instance.port };
494
+ }
495
+ const accountHolding = journal.phase === 'legacy-holding' &&
496
+ this.task.holdKind === 'automatic-triggers' &&
497
+ typeof this.task.verifyBinding === 'function';
498
+ if (['legacy-prepared', 'legacy-held'].includes(journal.phase) ||
499
+ accountHolding) {
500
+ this.verifySource(instance, journal.legacy);
501
+ await this.taskMatches(instance, journal.legacy.taskCurrent);
502
+ const api = await this.processApi();
503
+ const old = await api.prepareLegacyProcesses({
504
+ port: instance.port, root: journal.legacy.root, expected: journal.legacy.processes,
505
+ });
506
+ try {
507
+ await verifyLegacyListener(instance, journal.legacy);
508
+ if (journal.phase !== 'legacy-prepared') {
509
+ journal.legacy.taskCurrent = await this.task.restore(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
510
+ this.assertRestoredTask(instance.port, journal.legacy.taskCurrent, journal.legacy.registration);
511
+ }
512
+ this.transition(instance, journal, 'legacy-aborted');
513
+ return { status: 'legacy-aborted', directory, port: instance.port };
514
+ } finally { await old.close(); }
515
+ }
516
+ if (journal.phase === 'legacy-stopping') {
517
+ const api = await this.processApi();
518
+ const legacyStop = await api.verifyLegacyProcessesGone(journal.legacy.processes);
519
+ this.assertPartialStop(legacyStop);
520
+ this.transition(instance, journal, 'legacy-stopped', { legacyStop });
521
+ }
522
+ if (['legacy-stopped', 'legacy-launching', 'legacy-wiring'].includes(journal.phase)) {
523
+ return await this.rollback(instance, journal);
524
+ }
525
+ 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.`);
526
+ }
527
+ }