@adhdev/daemon-core 1.0.18-rc.1 → 1.0.18-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
- package/dist/cli-adapters/pty-transport.d.ts +2 -1
- package/dist/commands/process-lifecycle.d.ts +43 -0
- package/dist/commands/upgrade-helper.d.ts +7 -0
- package/dist/commands/windows-atomic-upgrade.d.ts +63 -0
- package/dist/index.js +5818 -4496
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5853 -4525
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +76 -0
- package/dist/mesh/mesh-disk-retention.d.ts +105 -0
- package/dist/mesh/mesh-ledger.d.ts +28 -1
- package/dist/mesh/mesh-runtime-store.d.ts +11 -0
- package/dist/providers/cli-provider-instance-types.d.ts +2 -0
- package/dist/providers/cli-provider-instance.d.ts +2 -0
- package/package.json +3 -3
- package/src/boot/daemon-lifecycle.ts +10 -0
- package/src/cli-adapters/cli-state-engine.ts +204 -3
- package/src/cli-adapters/provider-cli-adapter.ts +39 -5
- package/src/cli-adapters/pty-transport.d.ts +2 -1
- package/src/cli-adapters/pty-transport.ts +1 -1
- package/src/cli-adapters/session-host-transport.ts +8 -3
- package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
- package/src/commands/process-lifecycle.ts +248 -0
- package/src/commands/upgrade-helper.ts +146 -79
- package/src/commands/windows-atomic-upgrade.ts +631 -0
- package/src/config/mesh-json-config.ts +8 -0
- package/src/mesh/coordinator-prompt.ts +256 -10
- package/src/mesh/mesh-disk-retention.ts +370 -0
- package/src/mesh/mesh-ledger.ts +72 -0
- package/src/mesh/mesh-queue-assignment.ts +126 -1
- package/src/mesh/mesh-reconcile-loop.ts +49 -0
- package/src/mesh/mesh-runtime-store.ts +21 -0
- package/src/providers/cli-provider-instance-types.ts +25 -0
- package/src/providers/cli-provider-instance.ts +116 -0
- package/src/session-host/managed-host.ts +34 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { execFileSync, spawn, spawnSync, type ChildProcess } from 'child_process';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as http from 'http';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { stopOwnedProcessesForPrefixes } from './process-lifecycle.js';
|
|
6
|
+
|
|
7
|
+
const POINTER_NAME = '.adhdev-current';
|
|
8
|
+
const STABLE_FILES = [POINTER_NAME, 'adhdev.cmd', 'adhdev.ps1', 'adhdev'] as const;
|
|
9
|
+
const DEFAULT_HEALTH_PORT = 19222;
|
|
10
|
+
|
|
11
|
+
// How long to wait for the replacement daemon to report the target version
|
|
12
|
+
// before deterministically rolling back. status.version only appears AFTER the
|
|
13
|
+
// daemon fully boots its components — a separate session-host process, node-pty/
|
|
14
|
+
// conpty, CDP, and providers — which on a Windows cold self-upgrade routinely
|
|
15
|
+
// exceeds the old 30s ceiling. Live Windows logs showed every rollback clustering
|
|
16
|
+
// at 33–35s (30s timeout + poll overhead), so the gate was tripping on slow-boot,
|
|
17
|
+
// not on genuine failure. 120s reflects real Windows self-upgrade cold-start time.
|
|
18
|
+
export const DEFAULT_HEALTH_TIMEOUT_MS = 120_000;
|
|
19
|
+
|
|
20
|
+
function fetchLocalJson(port: number, pathname: string): Promise<{ ok: boolean; body: string }> {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const req = http.get(`http://127.0.0.1:${port}${pathname}`, { timeout: 1500 }, (res) => {
|
|
23
|
+
let body = '';
|
|
24
|
+
res.setEncoding('utf8');
|
|
25
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
26
|
+
res.on('end', () => resolve({ ok: res.statusCode === 200, body }));
|
|
27
|
+
});
|
|
28
|
+
req.on('timeout', () => req.destroy());
|
|
29
|
+
req.on('error', () => resolve({ ok: false, body: '' }));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Liveness + pid identity: GET /health returns {ok, pid, wsPath, port}.
|
|
34
|
+
async function fetchLocalHealth(port: number): Promise<{ ok: boolean; pid?: number }> {
|
|
35
|
+
const { ok, body } = await fetchLocalJson(port, '/health');
|
|
36
|
+
if (!ok) return { ok: false };
|
|
37
|
+
try {
|
|
38
|
+
const pid = Number(JSON.parse(body)?.pid);
|
|
39
|
+
return { ok: true, pid: Number.isFinite(pid) ? pid : undefined };
|
|
40
|
+
} catch {
|
|
41
|
+
return { ok: false };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Running version: GET /api/v1/status exposes it at payload.status.version.
|
|
46
|
+
// /health carries no version, so the upgrade version gate must read this.
|
|
47
|
+
async function fetchLocalStatusVersion(port: number): Promise<string | undefined> {
|
|
48
|
+
const { ok, body } = await fetchLocalJson(port, '/api/v1/status');
|
|
49
|
+
if (!ok) return undefined;
|
|
50
|
+
try {
|
|
51
|
+
const version = (JSON.parse(body) as { status?: { version?: unknown } })?.status?.version;
|
|
52
|
+
return typeof version === 'string' ? version : undefined;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Markers that identify a Node process as ADHDev-owned when its command line
|
|
59
|
+
// also lives under a versioned install prefix. This deliberately excludes
|
|
60
|
+
// arbitrary user scripts that happen to be located under ~/.adhdev.
|
|
61
|
+
export const ADHDEV_OWNED_MARKERS = [
|
|
62
|
+
'session-host-daemon',
|
|
63
|
+
'node_modules/adhdev',
|
|
64
|
+
'node_modules/@adhdev/daemon-standalone',
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
export interface WindowsInstallerLayout {
|
|
68
|
+
homeDir: string;
|
|
69
|
+
installRoot: string;
|
|
70
|
+
stablePrefix: string;
|
|
71
|
+
activePrefix: string;
|
|
72
|
+
activeVersionName: string;
|
|
73
|
+
pointerPath: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface WindowsAtomicUpgradeHooks {
|
|
77
|
+
install: (stagedPrefix: string, portableNode: string) => void | Promise<void>;
|
|
78
|
+
restart: (portableNode: string, stagedCliEntry: string) => ChildProcess;
|
|
79
|
+
restartOld: (portableNode: string) => void;
|
|
80
|
+
waitForHealth: (pid: number, targetVersion: string) => Promise<boolean>;
|
|
81
|
+
stopProcess: (pid: number) => void;
|
|
82
|
+
cleanup: (layout: WindowsInstallerLayout, activePrefix: string) => void | Promise<void>;
|
|
83
|
+
log: (message: string) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface WindowsAtomicUpgradeOptions {
|
|
87
|
+
layout: WindowsInstallerLayout;
|
|
88
|
+
packageName: string;
|
|
89
|
+
targetVersion: string;
|
|
90
|
+
portableNode: string;
|
|
91
|
+
hooks: WindowsAtomicUpgradeHooks;
|
|
92
|
+
/** PIDs that must never be terminated during prefix sweeps (helper + parent daemon). */
|
|
93
|
+
excludePids?: number[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface WindowsAtomicUpgradeResult {
|
|
97
|
+
stagedPrefix: string;
|
|
98
|
+
stagedCliEntry: string;
|
|
99
|
+
daemonPid: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeForCompare(value: string): string {
|
|
103
|
+
return path.resolve(value).replace(/[\\/]+$/, '').toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveWindowsInstallerLayout(options: {
|
|
107
|
+
homeDir: string;
|
|
108
|
+
installPrefix: string | null;
|
|
109
|
+
platform?: NodeJS.Platform;
|
|
110
|
+
}): WindowsInstallerLayout | null {
|
|
111
|
+
if ((options.platform || process.platform) !== 'win32' || !options.installPrefix) return null;
|
|
112
|
+
const installRoot = path.join(options.homeDir, '.adhdev', 'npm-installs');
|
|
113
|
+
const stablePrefix = path.join(options.homeDir, '.adhdev', 'npm-global');
|
|
114
|
+
const pointerPath = path.join(stablePrefix, POINTER_NAME);
|
|
115
|
+
const activeVersionName = path.basename(options.installPrefix);
|
|
116
|
+
if (!activeVersionName.startsWith('version-')) return null;
|
|
117
|
+
if (normalizeForCompare(path.dirname(options.installPrefix)) !== normalizeForCompare(installRoot)) return null;
|
|
118
|
+
return {
|
|
119
|
+
homeDir: options.homeDir,
|
|
120
|
+
installRoot,
|
|
121
|
+
stablePrefix,
|
|
122
|
+
activePrefix: options.installPrefix,
|
|
123
|
+
activeVersionName,
|
|
124
|
+
pointerPath,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function nodeMajor(nodeExecutable: string): number | null {
|
|
129
|
+
try {
|
|
130
|
+
const version = String(execFileSync(nodeExecutable, ['-p', 'process.versions.node'], {
|
|
131
|
+
encoding: 'utf8',
|
|
132
|
+
timeout: 5000,
|
|
133
|
+
windowsHide: true,
|
|
134
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
135
|
+
})).trim();
|
|
136
|
+
const major = Number.parseInt(version.split('.')[0], 10);
|
|
137
|
+
return Number.isFinite(major) ? major : null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function findPortableNode22(homeDir: string, currentNode: string = process.execPath): string | null {
|
|
144
|
+
const candidates: string[] = [currentNode];
|
|
145
|
+
const portableRoot = path.join(homeDir, '.adhdev', 'tools', 'node22');
|
|
146
|
+
try {
|
|
147
|
+
const dirs = fs.readdirSync(portableRoot, { withFileTypes: true })
|
|
148
|
+
.filter((entry) => entry.isDirectory())
|
|
149
|
+
.map((entry) => path.join(portableRoot, entry.name, 'node.exe'));
|
|
150
|
+
candidates.push(...dirs);
|
|
151
|
+
} catch {
|
|
152
|
+
// The installer-managed layout is incomplete; the caller will fail safely.
|
|
153
|
+
}
|
|
154
|
+
for (const candidate of candidates) {
|
|
155
|
+
if (candidate && fs.existsSync(candidate) && nodeMajor(candidate) === 22) return candidate;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function packageRootForPrefix(prefix: string, packageName: string): string {
|
|
161
|
+
return path.join(prefix, 'node_modules', ...packageName.split('/'));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// node-pty ships a Windows x64 prebuild. If npm rebuilds from source (because a
|
|
165
|
+
// .npmrc sets build-from-source=true), the install script deletes the prebuild
|
|
166
|
+
// and may leave no conpty.node on machines without build tools. Verify it
|
|
167
|
+
// survived before we ever activate the staged prefix.
|
|
168
|
+
const CONPTY_PREBUILD_RELATIVE_PATH = path.join(
|
|
169
|
+
'node_modules', 'adhdev', 'node_modules', 'node-pty', 'prebuilds', 'win32-x64', 'conpty.node'
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
function resolveStagedConptyPrebuildPath(stagedPrefix: string): string {
|
|
173
|
+
return path.join(stagedPrefix, CONPTY_PREBUILD_RELATIVE_PATH);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function verifyStagedConptyPrebuild(stagedPrefix: string): void {
|
|
177
|
+
const conptyPath = resolveStagedConptyPrebuildPath(stagedPrefix);
|
|
178
|
+
if (!fs.existsSync(conptyPath)) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Staged install is missing required native addon: ${conptyPath}. ` +
|
|
181
|
+
'Aborting activation to prevent a daemon boot crash.'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readPackageCliEntry(prefix: string, packageName: string, targetVersion: string): string {
|
|
187
|
+
const packageRoot = packageRootForPrefix(prefix, packageName);
|
|
188
|
+
const packageJsonPath = path.join(packageRoot, 'package.json');
|
|
189
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
|
|
190
|
+
name?: string;
|
|
191
|
+
version?: string;
|
|
192
|
+
bin?: string | Record<string, string>;
|
|
193
|
+
};
|
|
194
|
+
if (pkg.name !== packageName) throw new Error(`staged package name mismatch: ${pkg.name || 'missing'}`);
|
|
195
|
+
if (pkg.version !== targetVersion) throw new Error(`staged package version mismatch: ${pkg.version || 'missing'}`);
|
|
196
|
+
const bin = typeof pkg.bin === 'string'
|
|
197
|
+
? pkg.bin
|
|
198
|
+
: pkg.bin?.adhdev || (pkg.bin ? Object.values(pkg.bin)[0] : undefined);
|
|
199
|
+
if (!bin) throw new Error('staged package has no CLI entry');
|
|
200
|
+
const cliEntry = path.resolve(packageRoot, bin);
|
|
201
|
+
if (!fs.existsSync(cliEntry)) throw new Error(`staged CLI entry is missing: ${cliEntry}`);
|
|
202
|
+
return cliEntry;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function quotePowerShellLiteral(value: string): string {
|
|
206
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function pinStagedShims(prefix: string, portableNode: string, cliEntry: string): void {
|
|
210
|
+
const cmdPath = path.join(prefix, 'adhdev.cmd');
|
|
211
|
+
const ps1Path = path.join(prefix, 'adhdev.ps1');
|
|
212
|
+
// The no-extension `adhdev` shim is what `where.exe adhdev` can resolve first
|
|
213
|
+
// and what git-bash / MSYS / WSL and `spawnSync('adhdev')` invoke. npm generates
|
|
214
|
+
// it as a POSIX `sh` shim whose ELSE branch falls back to the FIRST `node` on
|
|
215
|
+
// PATH (`else exec node ...`). On a box where system PATH node is v24, that
|
|
216
|
+
// fallback trips adhdev's own Node-24 guard and `adhdev doctor` reports the
|
|
217
|
+
// runtime surface broken. Rewrite it too so it hard-codes the portable Node 22
|
|
218
|
+
// absolute path with NO system-node fallback — mirroring the .cmd/.ps1 pins.
|
|
219
|
+
const noExtPath = path.join(prefix, 'adhdev');
|
|
220
|
+
const cmd = `@echo off\r\n"${portableNode}" "${cliEntry}" %*\r\n`;
|
|
221
|
+
const ps1 = `#!/usr/bin/env pwsh\r\n& ${quotePowerShellLiteral(portableNode)} ${quotePowerShellLiteral(cliEntry)} @args\r\nexit $LASTEXITCODE\r\n`;
|
|
222
|
+
// sh shim: single unconditional exec of the pinned node — no `if -x node` /
|
|
223
|
+
// `else exec node` branch, so PATH ordering can never shadow the runtime.
|
|
224
|
+
const noExt = `#!/bin/sh\nexec "${portableNode}" "${cliEntry}" "$@"\n`;
|
|
225
|
+
fs.writeFileSync(cmdPath, cmd, 'ascii');
|
|
226
|
+
fs.writeFileSync(ps1Path, ps1, 'utf8');
|
|
227
|
+
fs.writeFileSync(noExtPath, noExt, 'ascii');
|
|
228
|
+
const cmdReadback = fs.readFileSync(cmdPath, 'utf8');
|
|
229
|
+
const ps1Readback = fs.readFileSync(ps1Path, 'utf8');
|
|
230
|
+
const noExtReadback = fs.readFileSync(noExtPath, 'utf8');
|
|
231
|
+
if (
|
|
232
|
+
!cmdReadback.includes(portableNode)
|
|
233
|
+
|| !ps1Readback.includes(portableNode)
|
|
234
|
+
|| !noExtReadback.includes(portableNode)
|
|
235
|
+
|| /(^|\s)exec\s+node(\s|$)/m.test(noExtReadback)
|
|
236
|
+
) {
|
|
237
|
+
throw new Error('portable Node 22 pin validation failed');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function validateStagedCli(portableNode: string, cliEntry: string, targetVersion: string): void {
|
|
242
|
+
const output = String(execFileSync(portableNode, [cliEntry, '--version'], {
|
|
243
|
+
encoding: 'utf8',
|
|
244
|
+
timeout: 15000,
|
|
245
|
+
windowsHide: true,
|
|
246
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
247
|
+
})).trim();
|
|
248
|
+
if (!output.includes(targetVersion)) {
|
|
249
|
+
throw new Error(`staged CLI version check failed: expected ${targetVersion}, received ${output || 'no output'}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function atomicWrite(destination: string, content: string, encoding: BufferEncoding): void {
|
|
254
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
255
|
+
if (process.platform === 'win32') {
|
|
256
|
+
const bytes = Buffer.from(content, encoding);
|
|
257
|
+
const payload = Buffer.from(JSON.stringify({ destination, bytes: bytes.toString('base64') }), 'utf8').toString('base64');
|
|
258
|
+
const script = [
|
|
259
|
+
`$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json`,
|
|
260
|
+
`$destination = [string]$payload.destination`,
|
|
261
|
+
`$temporary = "$destination.tmp-$PID-$([Guid]::NewGuid().ToString('N'))"`,
|
|
262
|
+
`$backup = "$destination.backup-$PID-$([Guid]::NewGuid().ToString('N'))"`,
|
|
263
|
+
`try {`,
|
|
264
|
+
` [IO.File]::WriteAllBytes($temporary, [Convert]::FromBase64String([string]$payload.bytes))`,
|
|
265
|
+
` if ([IO.File]::Exists($destination)) {`,
|
|
266
|
+
` [IO.File]::Replace($temporary, $destination, $backup, $true)`,
|
|
267
|
+
` if ([IO.File]::Exists($backup)) { [IO.File]::Delete($backup) }`,
|
|
268
|
+
` } else { [IO.File]::Move($temporary, $destination) }`,
|
|
269
|
+
`} finally { if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } }`,
|
|
270
|
+
].join('\n');
|
|
271
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
272
|
+
const result = spawnSync('powershell.exe', [
|
|
273
|
+
'-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
|
|
274
|
+
], { timeout: 10000, windowsHide: true, encoding: 'utf8' });
|
|
275
|
+
if (result.error || result.status !== 0) {
|
|
276
|
+
throw new Error(`atomic file replacement failed for ${destination}: ${result.error?.message || result.stderr || `exit ${result.status}`}`);
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const temporary = `${destination}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
281
|
+
fs.writeFileSync(temporary, content, encoding);
|
|
282
|
+
try {
|
|
283
|
+
fs.renameSync(temporary, destination);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
try { fs.unlinkSync(temporary); } catch { /* noop */ }
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function stableShimContents(): Record<'adhdev.cmd' | 'adhdev.ps1' | 'adhdev', { content: string; encoding: BufferEncoding }> {
|
|
291
|
+
return {
|
|
292
|
+
'adhdev.cmd': {
|
|
293
|
+
content: '@echo off\r\nsetlocal\r\nset /p "_ADHDEV_VERSION="<"%~dp0.adhdev-current"\r\nif not defined _ADHDEV_VERSION exit /b 1\r\ncall "%~dp0..\\npm-installs\\%_ADHDEV_VERSION%\\adhdev.cmd" %*\r\nexit /b %ERRORLEVEL%\r\n',
|
|
294
|
+
encoding: 'ascii',
|
|
295
|
+
},
|
|
296
|
+
'adhdev.ps1': {
|
|
297
|
+
content: "$versionName = [System.IO.File]::ReadAllText((Join-Path $PSScriptRoot '.adhdev-current')).Trim()\r\n$adhdevPrefix = Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'npm-installs') $versionName\r\n& (Join-Path $adhdevPrefix 'adhdev.ps1') @args\r\nexit $LASTEXITCODE\r\n",
|
|
298
|
+
encoding: 'utf8',
|
|
299
|
+
},
|
|
300
|
+
adhdev: {
|
|
301
|
+
content: '#!/bin/sh\nadhdev_version="$(cat "$(dirname "$0")/.adhdev-current")"\nadhdev_prefix="$(dirname "$(dirname "$0")")/npm-installs/$adhdev_version"\nexec "$adhdev_prefix/adhdev" "$@"\n',
|
|
302
|
+
encoding: 'ascii',
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
type FileSnapshot = { exists: boolean; data?: Buffer };
|
|
308
|
+
|
|
309
|
+
function snapshotStableFiles(stablePrefix: string): Map<string, FileSnapshot> {
|
|
310
|
+
const snapshots = new Map<string, FileSnapshot>();
|
|
311
|
+
for (const name of STABLE_FILES) {
|
|
312
|
+
const target = path.join(stablePrefix, name);
|
|
313
|
+
try {
|
|
314
|
+
snapshots.set(target, { exists: true, data: fs.readFileSync(target) });
|
|
315
|
+
} catch {
|
|
316
|
+
snapshots.set(target, { exists: false });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return snapshots;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Rollback must never leave PATH `adhdev` broken. Two failure modes made the old
|
|
323
|
+
// "restore-or-delete" logic dangerous:
|
|
324
|
+
// 1. If a stable shim (adhdev.cmd/.ps1/no-ext) did not exist at snapshot time —
|
|
325
|
+
// a first/partial install, or a stable tree that never had the launcher —
|
|
326
|
+
// deleting it leaves `where.exe adhdev` / `spawnSync('adhdev')` resolving to
|
|
327
|
+
// nothing (ENOENT). `adhdev doctor` then reports the runtime surface broken.
|
|
328
|
+
// 2. If the pointer (.adhdev-current) was absent at snapshot time, deleting it
|
|
329
|
+
// strands the re-published shims with no version to redirect to.
|
|
330
|
+
// So rollback (re)guarantees a valid launcher surface: existing snapshots restore
|
|
331
|
+
// their original bytes atomically; missing shims are re-issued from the canonical
|
|
332
|
+
// pointer-redirect launcher contents; and the pointer, when it has no snapshot,
|
|
333
|
+
// is re-written to the last-known-good active version instead of removed.
|
|
334
|
+
function restoreStableFiles(snapshots: Map<string, FileSnapshot>, layout: WindowsInstallerLayout): void {
|
|
335
|
+
const shims = stableShimContents();
|
|
336
|
+
for (const [target, snapshot] of snapshots) {
|
|
337
|
+
if (snapshot.exists && snapshot.data) {
|
|
338
|
+
atomicWrite(target, snapshot.data.toString('binary'), 'binary');
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const name = path.basename(target);
|
|
342
|
+
if (name === 'adhdev.cmd' || name === 'adhdev.ps1' || name === 'adhdev') {
|
|
343
|
+
// Re-issue a valid pointer-redirect launcher rather than deleting it, so
|
|
344
|
+
// PATH `adhdev` always resolves after a rollback.
|
|
345
|
+
atomicWrite(target, shims[name].content, shims[name].encoding);
|
|
346
|
+
} else if (name === POINTER_NAME) {
|
|
347
|
+
// Preserve the last-successful (currently active) version so the redirect
|
|
348
|
+
// launchers still reach a real prefix. Only fall back to deleting when we
|
|
349
|
+
// have no active version to point at.
|
|
350
|
+
if (layout.activeVersionName) atomicWrite(target, layout.activeVersionName, 'ascii');
|
|
351
|
+
else try { fs.unlinkSync(target); } catch { /* noop */ }
|
|
352
|
+
} else {
|
|
353
|
+
try { fs.unlinkSync(target); } catch { /* noop */ }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function publishStableShimsAndPointer(layout: WindowsInstallerLayout, versionName: string): void {
|
|
359
|
+
const shims = stableShimContents();
|
|
360
|
+
for (const name of ['adhdev.cmd', 'adhdev.ps1', 'adhdev'] as const) {
|
|
361
|
+
atomicWrite(path.join(layout.stablePrefix, name), shims[name].content, shims[name].encoding);
|
|
362
|
+
}
|
|
363
|
+
// Pointer last: until this rename, every stable launcher still reaches the old prefix.
|
|
364
|
+
atomicWrite(layout.pointerPath, versionName, 'ascii');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function performWindowsAtomicUpgrade(options: WindowsAtomicUpgradeOptions): Promise<WindowsAtomicUpgradeResult> {
|
|
368
|
+
const { layout, packageName, targetVersion, portableNode, hooks } = options;
|
|
369
|
+
const versionName = `version-${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`;
|
|
370
|
+
const stagedPrefix = path.join(layout.installRoot, versionName);
|
|
371
|
+
const snapshots = snapshotStableFiles(layout.stablePrefix);
|
|
372
|
+
let activated = false;
|
|
373
|
+
let restarted: ChildProcess | null = null;
|
|
374
|
+
fs.mkdirSync(stagedPrefix, { recursive: false });
|
|
375
|
+
try {
|
|
376
|
+
hooks.log(`Installing ${packageName}@${targetVersion} into inactive prefix ${stagedPrefix}`);
|
|
377
|
+
await hooks.install(stagedPrefix, portableNode);
|
|
378
|
+
verifyStagedConptyPrebuild(stagedPrefix);
|
|
379
|
+
const stagedCliEntry = readPackageCliEntry(stagedPrefix, packageName, targetVersion);
|
|
380
|
+
pinStagedShims(stagedPrefix, portableNode, stagedCliEntry);
|
|
381
|
+
validateStagedCli(portableNode, stagedCliEntry, targetVersion);
|
|
382
|
+
hooks.log(`Validated staged CLI and portable Node 22 shims in ${stagedPrefix}`);
|
|
383
|
+
|
|
384
|
+
// Stop every ADHDev-owned process still executing from the current active
|
|
385
|
+
// prefix or the legacy stable shim tree before moving the pointer. A stale
|
|
386
|
+
// session-host left running here keeps node-pty's native addon mapped from
|
|
387
|
+
// the old tree and resolves lazy requires against a deleted prefix after
|
|
388
|
+
// activation. Survivors block activation so the pointer is never corrupted.
|
|
389
|
+
const excludedPids = new Set([process.pid, ...(options.excludePids ?? [])].filter((n) => Number.isFinite(n) && n > 0));
|
|
390
|
+
const preStop = await stopOwnedProcessesForPrefixes({
|
|
391
|
+
prefixes: [layout.activePrefix, layout.stablePrefix],
|
|
392
|
+
excludePids: Array.from(excludedPids),
|
|
393
|
+
markers: Array.from(ADHDEV_OWNED_MARKERS),
|
|
394
|
+
waitMs: 15_000,
|
|
395
|
+
log: hooks.log,
|
|
396
|
+
});
|
|
397
|
+
if (preStop.survivors.length > 0) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Cannot activate ${versionName}: ${preStop.survivors.length} owned process(es) still running under the current prefix`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
activated = true;
|
|
404
|
+
publishStableShimsAndPointer(layout, versionName);
|
|
405
|
+
hooks.log(`Atomically activated ${versionName}`);
|
|
406
|
+
restarted = hooks.restart(portableNode, stagedCliEntry);
|
|
407
|
+
const daemonPid = restarted.pid;
|
|
408
|
+
if (!daemonPid || !(await hooks.waitForHealth(daemonPid, targetVersion))) {
|
|
409
|
+
throw new Error('replacement daemon did not pass the health/version gate');
|
|
410
|
+
}
|
|
411
|
+
hooks.log(`Replacement daemon pid ${daemonPid} passed health for ${targetVersion}`);
|
|
412
|
+
await hooks.cleanup(layout, stagedPrefix);
|
|
413
|
+
return { stagedPrefix, stagedCliEntry, daemonPid };
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (restarted?.pid) hooks.stopProcess(restarted.pid);
|
|
416
|
+
if (activated) {
|
|
417
|
+
try {
|
|
418
|
+
restoreStableFiles(snapshots, layout);
|
|
419
|
+
hooks.log(`Rolled back activation to ${layout.activeVersionName}`);
|
|
420
|
+
} catch (rollbackError: any) {
|
|
421
|
+
hooks.log(`Stable-file rollback failed: ${rollbackError?.message || String(rollbackError)}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
try { hooks.restartOld(portableNode); } catch { hooks.log('Failed to restart the previous daemon during rollback'); }
|
|
425
|
+
try { await hooks.cleanup(layout, layout.activePrefix); } catch { /* failure path must preserve original error */ }
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function createDefaultWindowsAtomicHooks(options: {
|
|
431
|
+
packageName: string;
|
|
432
|
+
targetVersion: string;
|
|
433
|
+
npmCliPath: string;
|
|
434
|
+
restartArgv: string[];
|
|
435
|
+
cwd: string;
|
|
436
|
+
env: NodeJS.ProcessEnv;
|
|
437
|
+
log: (message: string) => void;
|
|
438
|
+
/** Loopback IPC port to probe for health/version. Defaults to the daemon's local IPC port. */
|
|
439
|
+
healthPort?: number;
|
|
440
|
+
/** How long to poll for the replacement daemon to report the target version. */
|
|
441
|
+
healthTimeoutMs?: number;
|
|
442
|
+
}): WindowsAtomicUpgradeHooks {
|
|
443
|
+
const healthPort = options.healthPort ?? DEFAULT_HEALTH_PORT;
|
|
444
|
+
const healthTimeoutMs = options.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS;
|
|
445
|
+
return {
|
|
446
|
+
install: (stagedPrefix, portableNode) => {
|
|
447
|
+
const env: NodeJS.ProcessEnv = {
|
|
448
|
+
...options.env,
|
|
449
|
+
ADHDEV_BOOTSTRAP: '1',
|
|
450
|
+
npm_config_build_from_source: 'false',
|
|
451
|
+
'npm_config_build-from-source': 'false',
|
|
452
|
+
};
|
|
453
|
+
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'Path';
|
|
454
|
+
env[pathKey] = `${path.dirname(portableNode)};${env[pathKey] || ''}`;
|
|
455
|
+
const installOutput = String(execFileSync(portableNode, [
|
|
456
|
+
options.npmCliPath,
|
|
457
|
+
'install', '-g', `${options.packageName}@${options.targetVersion}`, '--force', '--prefer-online', '--prefix', stagedPrefix,
|
|
458
|
+
], {
|
|
459
|
+
encoding: 'utf8',
|
|
460
|
+
stdio: 'pipe',
|
|
461
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
462
|
+
windowsHide: true,
|
|
463
|
+
env,
|
|
464
|
+
}));
|
|
465
|
+
// On failure execFileSync throws with stdout attached to the Error; on
|
|
466
|
+
// success it was previously discarded. Surface the npm output either way so
|
|
467
|
+
// a silently-succeeding-then-rolled-back upgrade leaves a diagnosable trail.
|
|
468
|
+
if (installOutput.trim()) options.log(installOutput.trim());
|
|
469
|
+
},
|
|
470
|
+
restart: (portableNode, stagedCliEntry) => {
|
|
471
|
+
const restartArgv = options.restartArgv.map((arg, index) => index === 0 ? stagedCliEntry : arg);
|
|
472
|
+
if (restartArgv.length === 0) throw new Error('replacement daemon restart arguments are missing');
|
|
473
|
+
const child = spawn(portableNode, restartArgv, {
|
|
474
|
+
detached: true,
|
|
475
|
+
stdio: 'ignore',
|
|
476
|
+
windowsHide: true,
|
|
477
|
+
cwd: options.cwd,
|
|
478
|
+
env: options.env,
|
|
479
|
+
});
|
|
480
|
+
child.unref();
|
|
481
|
+
return child;
|
|
482
|
+
},
|
|
483
|
+
restartOld: (portableNode) => {
|
|
484
|
+
if (options.restartArgv.length === 0) return;
|
|
485
|
+
const child = spawn(portableNode, options.restartArgv, {
|
|
486
|
+
detached: true,
|
|
487
|
+
stdio: 'ignore',
|
|
488
|
+
windowsHide: true,
|
|
489
|
+
cwd: options.cwd,
|
|
490
|
+
env: options.env,
|
|
491
|
+
});
|
|
492
|
+
child.unref();
|
|
493
|
+
},
|
|
494
|
+
waitForHealth: async (pid, targetVersion) => {
|
|
495
|
+
const startedAt = Date.now();
|
|
496
|
+
const deadline = startedAt + healthTimeoutMs;
|
|
497
|
+
let attempt = 0;
|
|
498
|
+
// Log the transition milestones exactly once so the daemon log shows how far
|
|
499
|
+
// the replacement got before the gate resolved: not-yet-alive → alive but
|
|
500
|
+
// version-not-yet-ready (components still booting) → version matched. This is
|
|
501
|
+
// what pins down which boot stage the full-boot budget is being spent in.
|
|
502
|
+
let loggedAlive = false;
|
|
503
|
+
let loggedVersionPending = false;
|
|
504
|
+
while (Date.now() < deadline) {
|
|
505
|
+
attempt += 1;
|
|
506
|
+
// Liveness + pid identity come from GET /health, whose body is
|
|
507
|
+
// {ok, pid, wsPath, port} — it carries NO version. The running version
|
|
508
|
+
// lives only in GET /api/v1/status → payload.status.version, so the
|
|
509
|
+
// version gate must fetch that endpoint separately. Requiring the raw
|
|
510
|
+
// /health body to include targetVersion is unsatisfiable and silently
|
|
511
|
+
// rolls every upgrade back.
|
|
512
|
+
const liveness = await fetchLocalHealth(healthPort);
|
|
513
|
+
const alive = liveness.ok && liveness.pid === pid;
|
|
514
|
+
const version = alive ? await fetchLocalStatusVersion(healthPort) : undefined;
|
|
515
|
+
const elapsedMs = Date.now() - startedAt;
|
|
516
|
+
if (alive && version === targetVersion) {
|
|
517
|
+
options.log(`Health gate passed after ${elapsedMs}ms (${attempt} probe(s)): pid ${pid} reports ${targetVersion}`);
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
if (alive && !loggedAlive) {
|
|
521
|
+
loggedAlive = true;
|
|
522
|
+
options.log(`Health gate: replacement pid ${pid} is alive at ${elapsedMs}ms; awaiting status.version (components still booting)`);
|
|
523
|
+
}
|
|
524
|
+
if (alive && version && version !== targetVersion && !loggedVersionPending) {
|
|
525
|
+
loggedVersionPending = true;
|
|
526
|
+
options.log(`Health gate: replacement reports version ${version} (want ${targetVersion}) at ${elapsedMs}ms`);
|
|
527
|
+
}
|
|
528
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
529
|
+
}
|
|
530
|
+
options.log(`Health gate timed out after ${Date.now() - startedAt}ms (${attempt} probe(s), budget ${healthTimeoutMs}ms) waiting for pid ${pid} to report ${targetVersion}`);
|
|
531
|
+
return false;
|
|
532
|
+
},
|
|
533
|
+
stopProcess: (pid) => {
|
|
534
|
+
try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch { /* noop */ }
|
|
535
|
+
},
|
|
536
|
+
cleanup: async (layout, activePrefix) => cleanupInactivePrefixesWithGuard({
|
|
537
|
+
layout,
|
|
538
|
+
activePrefix,
|
|
539
|
+
excludePids: [process.pid],
|
|
540
|
+
markers: Array.from(ADHDEV_OWNED_MARKERS),
|
|
541
|
+
waitMs: 15_000,
|
|
542
|
+
log: options.log,
|
|
543
|
+
}),
|
|
544
|
+
log: options.log,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function boundedCleanupInactivePrefixes(
|
|
549
|
+
layout: WindowsInstallerLayout,
|
|
550
|
+
activePrefix: string,
|
|
551
|
+
log: (message: string) => void,
|
|
552
|
+
): void {
|
|
553
|
+
let candidates: string[] = [];
|
|
554
|
+
try {
|
|
555
|
+
candidates = fs.readdirSync(layout.installRoot, { withFileTypes: true })
|
|
556
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('version-'))
|
|
557
|
+
.map((entry) => path.join(layout.installRoot, entry.name))
|
|
558
|
+
.filter((entry) => normalizeForCompare(entry) !== normalizeForCompare(activePrefix))
|
|
559
|
+
.sort()
|
|
560
|
+
.slice(0, 8);
|
|
561
|
+
} catch {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (candidates.length === 0) return;
|
|
565
|
+
// Delete in-process (see removeInactivePrefix) rather than via a powershell.exe
|
|
566
|
+
// Remove-Item batch under a 5000ms spawnSync timeout, which ETIMEDOUT'd on
|
|
567
|
+
// Windows and left orphan version-* prefixes behind. best-effort: a failure
|
|
568
|
+
// here never blocks the upgrade success/failure signal.
|
|
569
|
+
let incomplete = false;
|
|
570
|
+
for (const candidate of candidates) {
|
|
571
|
+
try {
|
|
572
|
+
fs.rmSync(candidate, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
573
|
+
} catch {
|
|
574
|
+
incomplete = true;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (incomplete) log('Bounded inactive-prefix cleanup was incomplete; future updates will retry');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export async function cleanupInactivePrefixesWithGuard(options: {
|
|
581
|
+
layout: WindowsInstallerLayout;
|
|
582
|
+
activePrefix: string;
|
|
583
|
+
excludePids?: number[];
|
|
584
|
+
markers?: readonly string[];
|
|
585
|
+
waitMs?: number;
|
|
586
|
+
log?: (message: string) => void;
|
|
587
|
+
}): Promise<void> {
|
|
588
|
+
const { layout, activePrefix, log } = options;
|
|
589
|
+
let candidates: string[] = [];
|
|
590
|
+
try {
|
|
591
|
+
candidates = fs.readdirSync(layout.installRoot, { withFileTypes: true })
|
|
592
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('version-'))
|
|
593
|
+
.map((entry) => path.join(layout.installRoot, entry.name))
|
|
594
|
+
.filter((entry) => normalizeForCompare(entry) !== normalizeForCompare(activePrefix))
|
|
595
|
+
.sort()
|
|
596
|
+
.slice(0, 8);
|
|
597
|
+
} catch {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (candidates.length === 0) return;
|
|
601
|
+
|
|
602
|
+
for (const candidate of candidates) {
|
|
603
|
+
const stopResult = await stopOwnedProcessesForPrefixes({
|
|
604
|
+
prefixes: [candidate],
|
|
605
|
+
excludePids: options.excludePids,
|
|
606
|
+
markers: options.markers,
|
|
607
|
+
waitMs: options.waitMs ?? 15_000,
|
|
608
|
+
log,
|
|
609
|
+
});
|
|
610
|
+
if (stopResult.survivors.length > 0) {
|
|
611
|
+
log?.(`Skipping cleanup of ${candidate}: ${stopResult.survivors.length} owned process(es) could not be stopped`);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
removeInactivePrefix(candidate, log);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function removeInactivePrefix(target: string, log?: (message: string) => void): void {
|
|
619
|
+
// Delete in-process with fs.rmSync instead of shelling out to powershell.exe.
|
|
620
|
+
// A version prefix holds thousands of small files (node_modules, node-pty
|
|
621
|
+
// prebuilds); Remove-Item -Recurse -Force over that, plus PowerShell 5.1's
|
|
622
|
+
// cold-start, routinely blew past the old 5000ms spawnSync timeout on Windows
|
|
623
|
+
// — every candidate then failed with ETIMEDOUT and orphan version-* dirs
|
|
624
|
+
// accumulated. fs.rmSync has no process spawn, no timeout, and retries the
|
|
625
|
+
// transient EBUSY/EPERM/ENOTEMPTY that a just-stopped process can leave behind.
|
|
626
|
+
try {
|
|
627
|
+
fs.rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
628
|
+
} catch (error: any) {
|
|
629
|
+
log?.(`Failed to remove inactive prefix ${target}: ${error?.message || String(error)}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
@@ -160,6 +160,14 @@ function normalizeOperatingNote(value: unknown): CoordinatorOperatingNote | null
|
|
|
160
160
|
...(category ? { category } : {}),
|
|
161
161
|
...(typeof value.createdAt === 'string' ? { createdAt: value.createdAt } : {}),
|
|
162
162
|
...(typeof value.sourceCoordinator === 'string' ? { sourceCoordinator: value.sourceCoordinator } : {}),
|
|
163
|
+
// Operating-notes lifecycle: a repo-declared note may pin itself or set an
|
|
164
|
+
// explicit expiry, same as a runtime-recorded one. Phase 2 (b)/(c): it may
|
|
165
|
+
// also declare supersedes/subjectKey to retire an earlier note or group
|
|
166
|
+
// same-subject notes for folding.
|
|
167
|
+
...(value.pinned === true ? { pinned: true } : {}),
|
|
168
|
+
...(typeof value.expiresAt === 'string' ? { expiresAt: value.expiresAt } : {}),
|
|
169
|
+
...(typeof value.supersedes === 'string' ? { supersedes: value.supersedes } : {}),
|
|
170
|
+
...(typeof value.subjectKey === 'string' ? { subjectKey: value.subjectKey } : {}),
|
|
163
171
|
};
|
|
164
172
|
}
|
|
165
173
|
|