@hivemagnus/magnus-agent 1.1.2 → 1.1.3

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.
@@ -6,14 +6,34 @@
6
6
  * `magnus-agent install-service`) starts the new version on trial and switches
7
7
  * back if it does not report an accepted heartbeat in time. Nothing here uses
8
8
  * sudo: the agent only writes inside its own install directory.
9
+ *
10
+ * The state file and the links are shared with the launcher: both write them
11
+ * durably (temp file, fsync, rename, fsync of the directory) and both take
12
+ * upgrade-state.lock (O_EXCL) around a read-modify-write.
9
13
  */
10
14
  import { createHash } from 'node:crypto';
11
- import { spawn } from 'node:child_process';
12
- import { accessSync, constants, existsSync, lchownSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
13
- import { join, resolve } from 'node:path';
14
- import { AGENT_SELF_UPGRADE_PROTOCOL, checkUpgradeAllowed, compareVersions, } from '@hivemagnus/magnus-contracts';
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import { accessSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lchownSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from 'node:fs';
17
+ import { delimiter, dirname, join, resolve } from 'node:path';
18
+ import { AGENT_SELF_UPGRADE_PROTOCOL, checkUpgradeAllowed, compareVersions, satisfiesRange, } from '@hivemagnus/magnus-contracts';
19
+ import { childEnvironment } from '../utils/child-env.js';
20
+ import { logger } from '../utils/logger.js';
15
21
  export const STATE_FILE = 'upgrade-state.json';
22
+ export const LOCK_FILE = 'upgrade-state.lock';
23
+ /** Written while on trial when heartbeats cannot reach the control API; the launcher then extends the trial. */
24
+ export const ALIVE_FILE = 'trial-alive.json';
16
25
  export const AGENT_PACKAGE = '@hivemagnus/magnus-agent';
26
+ /** The launcher protocol this agent needs. Kept equal to `magnusAgent.minLauncher` in package.json. */
27
+ export const MIN_LAUNCHER_PROTOCOL = 1;
28
+ /** Oldest npm the upgrade steps are tested with (overrides, `audit signatures`). */
29
+ export const MIN_NPM_VERSION = '10.0.0';
30
+ /** Per-step limits and the total budget; the server waits 15 minutes for an upgrade. */
31
+ export const STEP_TIMEOUT_MS = { pack: 3 * 60_000, install: 8 * 60_000, audit: 3 * 60_000, smoke: 60_000 };
32
+ export const STAGE_BUDGET_MS = 13 * 60_000;
33
+ /** After staging, the agent exits even if a graceful shutdown hangs. */
34
+ export const EXIT_BACKSTOP_MS = 60_000;
35
+ const NPM_NETWORK = ['--fetch-timeout=60000', '--fetch-retries=2'];
36
+ const ENTRY = ['node_modules', '@hivemagnus', 'magnus-agent', 'dist', 'index.js'];
17
37
  export function installDir(env = process.env) {
18
38
  return resolve(env['AGENT_INSTALL_DIR'] ?? process.cwd());
19
39
  }
@@ -46,19 +66,138 @@ export function adoptInstallOwner(dir, path, uid = process.getuid?.()) {
46
66
  if (lstatSync(path, { throwIfNoEntry: false }))
47
67
  walk(path);
48
68
  }
49
- export function writeUpgradeState(dir, state) {
50
- const path = join(dir, STATE_FILE);
69
+ function fsyncDir(dir) {
70
+ try {
71
+ const fd = openSync(dir, 'r');
72
+ try {
73
+ fsyncSync(fd);
74
+ }
75
+ finally {
76
+ closeSync(fd);
77
+ }
78
+ }
79
+ catch {
80
+ // some filesystems cannot fsync a directory
81
+ }
82
+ }
83
+ /** Temp file, fsync, rename, fsync the directory: a crash leaves the old or the new content, never a torn file. */
84
+ export function writeDurable(dir, name, text, mode = 0o600) {
85
+ const path = join(dir, name);
51
86
  const temporary = `${path}.${process.pid}.tmp`;
52
- writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
53
- adoptInstallOwner(dir, temporary);
54
- renameSync(temporary, path);
87
+ try {
88
+ const fd = openSync(temporary, 'w', mode);
89
+ try {
90
+ writeSync(fd, text);
91
+ fsyncSync(fd);
92
+ }
93
+ finally {
94
+ closeSync(fd);
95
+ }
96
+ adoptInstallOwner(dir, temporary);
97
+ renameSync(temporary, path);
98
+ }
99
+ catch (error) {
100
+ rmSync(temporary, { force: true });
101
+ throw error;
102
+ }
103
+ fsyncDir(dir);
104
+ }
105
+ export function writeUpgradeState(dir, state) {
106
+ writeDurable(dir, STATE_FILE, `${JSON.stringify(state, null, 2)}\n`);
107
+ }
108
+ /** Atomically point `<dir>/<name>` (current, last-good) at versions/<version>. */
109
+ export function pointLink(dir, name, version) {
110
+ const temporary = join(dir, `${name}.${process.pid}.tmp`);
111
+ try {
112
+ rmSync(temporary, { force: true });
113
+ symlinkSync(join('versions', version), temporary, 'dir');
114
+ adoptInstallOwner(dir, temporary);
115
+ renameSync(temporary, join(dir, name));
116
+ }
117
+ catch (error) {
118
+ rmSync(temporary, { force: true });
119
+ throw error;
120
+ }
121
+ fsyncDir(dir);
55
122
  }
56
123
  export function pointCurrentAt(dir, version) {
57
- const temporary = join(dir, `current.${process.pid}.tmp`);
58
- rmSync(temporary, { force: true });
59
- symlinkSync(join('versions', version), temporary, 'dir');
60
- adoptInstallOwner(dir, temporary);
61
- renameSync(temporary, join(dir, 'current'));
124
+ pointLink(dir, 'current', version);
125
+ }
126
+ /** The X of a `<name> -> versions/X` link, or null. */
127
+ export function linkVersion(dir, name) {
128
+ try {
129
+ return /^versions[\\/]([^\\/]+)$/.exec(readlinkSync(join(dir, name)))?.[1] ?? null;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ /** versions/<version> holds a runnable agent. */
136
+ export function versionComplete(dir, version) {
137
+ return !!version && existsSync(join(dir, 'versions', version, ...ENTRY));
138
+ }
139
+ /** ./current resolves to versions/<version>. */
140
+ export function runsVersion(dir, version) {
141
+ try {
142
+ return realpathSync(join(dir, 'current')) === realpathSync(join(dir, 'versions', version));
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
148
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
149
+ function lockIsStale(path, staleMs) {
150
+ try {
151
+ const pid = Number.parseInt(readFileSync(path, 'utf8'), 10);
152
+ if (Number.isInteger(pid) && pid > 0) {
153
+ try {
154
+ process.kill(pid, 0);
155
+ }
156
+ catch (error) {
157
+ if (error.code === 'ESRCH')
158
+ return true;
159
+ }
160
+ }
161
+ return Date.now() - statSync(path).mtimeMs > staleMs;
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
167
+ /** O_EXCL lock shared with the launcher around a read-modify-write of the state file. Synchronous; waits up to timeoutMs. */
168
+ export function withStateLock(dir, fn, { timeoutMs = 3_000, staleMs = 15_000 } = {}) {
169
+ const path = join(dir, LOCK_FILE);
170
+ const until = Date.now() + timeoutMs;
171
+ let fd;
172
+ for (;;) {
173
+ try {
174
+ fd = openSync(path, 'wx', 0o600);
175
+ break;
176
+ }
177
+ catch (error) {
178
+ if (error.code !== 'EEXIST')
179
+ throw error;
180
+ if (lockIsStale(path, staleMs)) {
181
+ rmSync(path, { force: true });
182
+ continue;
183
+ }
184
+ if (Date.now() >= until)
185
+ throw Object.assign(new Error(`${LOCK_FILE} is held by another process`), { code: 'ELOCKED' });
186
+ Atomics.wait(sleeper, 0, 0, 25);
187
+ }
188
+ }
189
+ try {
190
+ writeSync(fd, String(process.pid));
191
+ }
192
+ finally {
193
+ closeSync(fd);
194
+ }
195
+ try {
196
+ return fn();
197
+ }
198
+ finally {
199
+ rmSync(path, { force: true });
200
+ }
62
201
  }
63
202
  export function detectLayout(dir, env = process.env) {
64
203
  if (env['MAGNUS_AGENT_CONTAINER'] === '1')
@@ -75,6 +214,63 @@ export function detectLayout(dir, env = process.env) {
75
214
  function selfUpgradeSwitch(env) {
76
215
  return (env['MAGNUS_AGENT_SELF_UPGRADE'] ?? 'true').trim().toLowerCase() !== 'false';
77
216
  }
217
+ function readJson(path) {
218
+ try {
219
+ return JSON.parse(readFileSync(path, 'utf8'));
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
225
+ /**
226
+ * The npm that belongs to the running Node.js (npm-cli.js beside it, run with
227
+ * process.execPath), then MAGNUS_AGENT_NPM, then npm on PATH. A systemd PATH
228
+ * often lacks the directory of a version-managed Node.js.
229
+ */
230
+ export function resolveNpm(env = process.env, execPath = process.execPath) {
231
+ const bin = dirname(execPath);
232
+ const configured = env['MAGNUS_AGENT_NPM']?.trim();
233
+ if (configured)
234
+ return { command: configured, args: [], pathPrefix: bin, version: null, problem: null };
235
+ for (const cli of [join(bin, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), join(bin, 'node_modules', 'npm', 'bin', 'npm-cli.js')]) {
236
+ if (!existsSync(cli))
237
+ continue;
238
+ const version = readJson(join(dirname(cli), '..', 'package.json'))?.version ?? null;
239
+ let problem = null;
240
+ try {
241
+ if (version && compareVersions(version, MIN_NPM_VERSION) < 0)
242
+ problem = `npm ${version} beside ${execPath} is older than ${MIN_NPM_VERSION}`;
243
+ }
244
+ catch {
245
+ // unparseable version: let npm itself decide
246
+ }
247
+ return { command: execPath, args: [cli], pathPrefix: bin, version, problem };
248
+ }
249
+ const folder = [bin, ...(env['PATH'] ?? '').split(delimiter)].find(entry => entry && existsSync(join(entry, 'npm')));
250
+ if (folder)
251
+ return { command: join(folder, 'npm'), args: [], pathPrefix: bin, version: null, problem: null };
252
+ return { command: 'npm', args: [], pathPrefix: bin, version: null, problem: `npm was not found beside ${execPath} or on PATH` };
253
+ }
254
+ function confirmWindowSeconds(env, layout) {
255
+ const fromLauncher = Number.parseInt(env['MAGNUS_AGENT_LAUNCHER_CONFIRM_SECONDS'] ?? '', 10);
256
+ if (Number.isInteger(fromLauncher) && fromLauncher > 0)
257
+ return fromLauncher;
258
+ if (layout !== 'versioned')
259
+ return null;
260
+ // Launcher protocol 1 does not pass the window: compute it the same way.
261
+ const value = Number.parseInt(env['MAGNUS_AGENT_UPGRADE_CONFIRM_SECONDS'] ?? '', 10);
262
+ return Number.isInteger(value) && value >= 10 ? Math.min(value, 3600) : 180;
263
+ }
264
+ function writable(path) {
265
+ try {
266
+ accessSync(path, constants.W_OK);
267
+ return true;
268
+ }
269
+ catch {
270
+ return false;
271
+ }
272
+ }
273
+ const clip = (text, max) => (text == null ? null : text.length > max ? `${text.slice(0, max - 1)}…` : text);
78
274
  /** Whether this agent can upgrade itself, and why not. Reported with every heartbeat. */
79
275
  export function selfUpgradeStatus(version, env = process.env) {
80
276
  const dir = installDir(env);
@@ -89,48 +285,144 @@ export function selfUpgradeStatus(version, env = process.env) {
89
285
  else if (layout === 'legacy')
90
286
  reason = 'not running under the upgrade launcher; run `sudo magnus-agent install-service` once on this node';
91
287
  else {
92
- try {
93
- accessSync(dir, constants.W_OK);
94
- accessSync(join(dir, 'versions'), constants.W_OK);
95
- }
96
- catch {
97
- reason = `the agent cannot write ${dir}`;
98
- }
288
+ // Paths left behind by root (an earlier privileged run) block an unprivileged agent.
289
+ const blocked = [dir, join(dir, 'versions'), ...[join(dir, 'staging'), join(dir, STATE_FILE)].filter(path => existsSync(path))]
290
+ .find(path => !writable(path));
291
+ if (blocked)
292
+ reason = `the agent cannot write ${blocked}; run \`sudo magnus-agent install-service\` again`;
293
+ else
294
+ reason = resolveNpm(env).problem;
99
295
  }
100
296
  return {
101
297
  protocol: AGENT_SELF_UPGRADE_PROTOCOL,
102
298
  launcherProtocol: Number.isInteger(launcherProtocol) ? launcherProtocol : null,
103
299
  layout,
104
300
  enabled: reason === null,
105
- reason,
301
+ reason: clip(reason, 300),
302
+ confirmSeconds: confirmWindowSeconds(env, layout),
106
303
  upgrade: state ? reportOf(state) : { state: 'idle', from: null, to: version },
107
304
  };
108
305
  }
109
306
  function reportOf(state) {
110
307
  return {
111
- state: state.state, from: state.from, to: state.to, requestId: state.requestId, reason: state.reason ?? null,
308
+ state: state.state, from: state.from, to: state.to, requestId: state.requestId, reason: clip(state.reason, 500),
112
309
  ...(state.attempt !== undefined ? { attempt: state.attempt } : {}), ...(state.updatedAt ? { updatedAt: state.updatedAt } : {}),
113
310
  };
114
311
  }
312
+ /** The upgrade state when `version` is the one on trial. */
313
+ export function trialOf(version, env = process.env) {
314
+ const state = readUpgradeState(installDir(env));
315
+ return state?.state === 'trial' && state.to === version ? state : null;
316
+ }
317
+ /** Tell the launcher this version runs but cannot reach the control API, so it extends the trial instead of reverting. */
318
+ export function recordTrialAlive(version, error, env = process.env, now = Date.now()) {
319
+ const state = trialOf(version, env);
320
+ if (!state)
321
+ return false;
322
+ try {
323
+ writeDurable(installDir(env), ALIVE_FILE, `${JSON.stringify({ version, requestId: state.requestId, at: now, error: error.slice(0, 200) })}\n`);
324
+ return true;
325
+ }
326
+ catch {
327
+ return false;
328
+ }
329
+ }
330
+ /** A failed heartbeat POST that says nothing about this version: no answer, or a gateway with no upstream. */
331
+ export function controlApiUnreachable(error) {
332
+ if (!(error instanceof Error))
333
+ return true;
334
+ if (error.name === 'HTTPError') {
335
+ const status = error.response?.status ?? 0;
336
+ return status === 502 || status === 503 || status === 504;
337
+ }
338
+ return true;
339
+ }
340
+ /** `export const LAUNCHER_PROTOCOL = N` in a launcher file, without running it. */
341
+ export function declaredLauncherProtocol(path) {
342
+ try {
343
+ const match = /^export const LAUNCHER_PROTOCOL = (\d+);$/m.exec(readFileSync(path, 'utf8'));
344
+ return match ? Number(match[1]) : null;
345
+ }
346
+ catch {
347
+ return null;
348
+ }
349
+ }
350
+ /**
351
+ * Replace launcher/launcher.mjs with the confirmed version's copy when that
352
+ * declares a higher protocol and passes a self-check (`--protocol`). The
353
+ * running launcher keeps its loaded code; the new one is used from the next
354
+ * service start. The previous file is kept as launcher.mjs.previous.
355
+ */
356
+ export function upgradeLauncher(dir, version, execPath = process.execPath) {
357
+ const candidate = join(dir, 'versions', version, 'node_modules', ...AGENT_PACKAGE.split('/'), 'launcher', 'launcher.mjs');
358
+ const folder = join(dir, 'launcher');
359
+ const installed = join(folder, 'launcher.mjs');
360
+ const offered = declaredLauncherProtocol(candidate);
361
+ const have = declaredLauncherProtocol(installed);
362
+ if (offered === null || have === null || offered <= have)
363
+ return null;
364
+ const probe = spawnSync(execPath, [candidate, '--protocol'], { encoding: 'utf8', timeout: 10_000, env: childEnvironment() });
365
+ if (probe.status !== 0 || probe.stdout.trim() !== String(offered))
366
+ throw new Error(`the launcher from ${version} failed its self-check`);
367
+ copyFileSync(installed, join(folder, 'launcher.mjs.previous'));
368
+ writeDurable(folder, 'launcher.mjs', readFileSync(candidate, 'utf8'), 0o644);
369
+ return `launcher protocol ${have} -> ${offered}`;
370
+ }
115
371
  /**
116
- * Called after every accepted heartbeat: the first one from a version on trial
117
- * confirms it, and versions other than the previous and the current one are pruned.
372
+ * Called after every accepted heartbeat: the first one from a version on
373
+ * trial confirms it (unless the trial window has closed or ./current no
374
+ * longer runs it), records it as last-good, and prunes other versions.
118
375
  */
119
376
  export function confirmTrial(version, env = process.env, now = new Date()) {
120
377
  const dir = installDir(env);
121
- const state = readUpgradeState(dir);
122
- if (!state || state.state !== 'trial' || state.to !== version)
378
+ let trial = null;
379
+ try {
380
+ withStateLock(dir, () => {
381
+ const state = readUpgradeState(dir);
382
+ if (!state || state.state !== 'trial' || state.to !== version)
383
+ return;
384
+ const deadline = Date.parse(state.trialDeadline ?? '');
385
+ // The launcher is reverting (or about to): a late confirmation must not race it.
386
+ if (Number.isFinite(deadline) && now.getTime() >= deadline)
387
+ return;
388
+ if (!runsVersion(dir, version))
389
+ return;
390
+ writeUpgradeState(dir, { ...state, state: 'confirmed', reason: null, updatedAt: now.toISOString() });
391
+ trial = state;
392
+ }, { timeoutMs: 1_000 }); // blocks the event loop while it waits; the launcher holds the lock for milliseconds
393
+ }
394
+ catch (error) {
395
+ logger.warn('Self-upgrade confirmation deferred', { error: error instanceof Error ? error.message : String(error) });
123
396
  return false;
124
- writeUpgradeState(dir, { ...state, state: 'confirmed', reason: null, updatedAt: now.toISOString() });
125
- pruneVersions(dir, [state.from, state.to]);
397
+ }
398
+ const confirmed = trial;
399
+ if (!confirmed)
400
+ return false;
401
+ const bestEffort = (step, action) => {
402
+ try {
403
+ action();
404
+ }
405
+ catch (error) {
406
+ logger.warn(`Self-upgrade: ${step} failed`, { error: error instanceof Error ? error.message : String(error) });
407
+ }
408
+ };
409
+ bestEffort('recording last-good', () => pointLink(dir, 'last-good', version));
410
+ bestEffort('removing the trial marker', () => rmSync(join(dir, ALIVE_FILE), { force: true }));
411
+ bestEffort('pruning old versions', () => pruneVersions(dir, [confirmed.from, confirmed.to]));
412
+ bestEffort('upgrading the launcher', () => {
413
+ const change = upgradeLauncher(dir, version);
414
+ if (change)
415
+ logger.info(`Self-upgrade: installed the launcher from ${version} (${change}); it takes over at the next service start`);
416
+ });
126
417
  return true;
127
418
  }
128
419
  export function pruneVersions(dir, keep) {
129
420
  const versions = join(dir, 'versions');
130
421
  if (!existsSync(versions))
131
422
  return;
423
+ const kept = [...keep, linkVersion(dir, 'current'), linkVersion(dir, 'last-good')];
132
424
  for (const name of readdirSync(versions)) {
133
- if (keep.includes(name))
425
+ if (kept.includes(name))
134
426
  continue;
135
427
  rmSync(join(versions, name), { recursive: true, force: true });
136
428
  }
@@ -144,14 +436,45 @@ export class SelfUpgradeError extends Error {
144
436
  this.status = status;
145
437
  }
146
438
  }
439
+ const OUTPUT_LIMIT = 64 * 1024;
440
+ const KILL_GRACE_MS = 5_000;
441
+ /** Runs a command in its own process group; at timeoutMs the whole group gets SIGTERM, then SIGKILL. */
147
442
  export const runProcess = (command, args, options) => new Promise((resolvePromise, reject) => {
148
- const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'] });
443
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
149
444
  let stdout = '';
150
445
  let stderr = '';
151
- child.stdout.on('data', chunk => { stdout += String(chunk); });
152
- child.stderr.on('data', chunk => { stderr += String(chunk); });
153
- child.on('error', reject);
154
- child.on('close', code => resolvePromise({ code: code ?? 1, stdout, stderr }));
446
+ let timedOut = false;
447
+ const timers = [];
448
+ const signalGroup = (signal) => {
449
+ try {
450
+ if (child.pid)
451
+ process.kill(-child.pid, signal);
452
+ }
453
+ catch {
454
+ try {
455
+ child.kill(signal);
456
+ }
457
+ catch { /* gone */ }
458
+ }
459
+ };
460
+ if (options.timeoutMs !== undefined) {
461
+ timers.push(setTimeout(() => {
462
+ timedOut = true;
463
+ signalGroup('SIGTERM');
464
+ timers.push(setTimeout(() => {
465
+ signalGroup('SIGKILL');
466
+ // A descendant outside the group may still hold the pipes: release them so `close` fires.
467
+ timers.push(setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); }, KILL_GRACE_MS));
468
+ }, KILL_GRACE_MS));
469
+ }, options.timeoutMs));
470
+ }
471
+ const settle = () => { for (const timer of timers)
472
+ clearTimeout(timer); };
473
+ const keep = (buffer, chunk) => (buffer + String(chunk)).slice(-OUTPUT_LIMIT);
474
+ child.stdout.on('data', chunk => { stdout = keep(stdout, chunk); });
475
+ child.stderr.on('data', chunk => { stderr = keep(stderr, chunk); });
476
+ child.on('error', error => { settle(); reject(error); });
477
+ child.on('close', code => { settle(); resolvePromise({ code: code ?? 1, stdout, stderr, timedOut }); });
155
478
  });
156
479
  /** npm pack names files `<scope>-<name>-<version>.tgz`. */
157
480
  export function tarballName(version) {
@@ -174,12 +497,31 @@ export function sourceOverrides(sourceDir, agentTarball) {
174
497
  }
175
498
  return overrides;
176
499
  }
177
- let staging = false;
500
+ const SIMPLE_RANGE = /^(?:\*|\^?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;
501
+ /** Why `engines.node` rules out `nodeVersion`, or null. Range forms satisfiesRange cannot judge are not refused. */
502
+ export function engineProblem(range, nodeVersion) {
503
+ if (typeof range !== 'string' || !range.trim())
504
+ return null;
505
+ if (!range.split('||').every(part => SIMPLE_RANGE.test(part.trim())))
506
+ return null;
507
+ return satisfiesRange(nodeVersion, range) ? null : `requires Node.js ${range.trim()}; this node runs ${nodeVersion}`;
508
+ }
509
+ let staging = null;
178
510
  /** Download, verify, install and switch. The caller exits afterwards so the launcher starts the new version. */
179
511
  export async function stageUpgrade(options) {
180
512
  const env = options.env ?? process.env;
181
513
  const run = options.run ?? runProcess;
514
+ const clock = options.clock ?? Date.now;
515
+ const started = clock();
182
516
  const { request, version: from } = options;
517
+ const dir = installDir(env);
518
+ // A retried request (same requestId and target) reports where it got to instead of failing.
519
+ const recorded = readUpgradeState(dir);
520
+ if ((recorded?.requestId === request.requestId && recorded.to === request.version)
521
+ || (staging?.requestId === request.requestId && staging.version === request.version)) {
522
+ return { from: recorded?.from ?? from, to: request.version, integrity: request.integrity, requestId: request.requestId, repeated: true,
523
+ upgrade: recorded?.requestId === request.requestId ? reportOf(recorded) : { state: 'idle', from, to: request.version, requestId: request.requestId, reason: 'being staged' } };
524
+ }
183
525
  const status = selfUpgradeStatus(from, env);
184
526
  if (!status.enabled)
185
527
  throw new SelfUpgradeError('SELF_UPGRADE_UNAVAILABLE', status.reason ?? 'self-upgrade is unavailable');
@@ -195,17 +537,37 @@ export async function stageUpgrade(options) {
195
537
  throw new SelfUpgradeError('SELF_UPGRADE_BUSY', `an upgrade to ${status.upgrade?.to} is still on trial`);
196
538
  if (staging)
197
539
  throw new SelfUpgradeError('SELF_UPGRADE_BUSY', 'an upgrade is already being staged');
198
- staging = true;
199
- const dir = installDir(env);
200
- const work = join(dir, 'staging', request.version);
540
+ // The launcher can only switch back to a complete versions/<from> that ./current runs now.
541
+ if (!versionComplete(dir, from) || !runsVersion(dir, from)) {
542
+ throw new SelfUpgradeError('SELF_UPGRADE_UNAVAILABLE', `./current does not run a complete versions/${from}, so a failed upgrade could not be reverted; run \`sudo magnus-agent install-service\` again`);
543
+ }
544
+ const npm = resolveNpm(env);
545
+ if (npm.problem)
546
+ throw new SelfUpgradeError('SELF_UPGRADE_UNAVAILABLE', npm.problem);
547
+ staging = { requestId: request.requestId, version: request.version };
548
+ const stagingRoot = join(dir, 'staging');
549
+ const work = join(stagingRoot, request.version);
201
550
  const target = join(dir, 'versions', request.version);
202
551
  try {
203
- rmSync(work, { recursive: true, force: true });
552
+ // Leftovers of interrupted upgrades, whatever version they were for.
553
+ rmSync(stagingRoot, { recursive: true, force: true });
204
554
  mkdirSync(work, { recursive: true, mode: 0o700 });
205
- const npm = env['MAGNUS_AGENT_NPM'] || 'npm';
206
- const npmEnv = { ...env, npm_config_cache: env['npm_config_cache'] ?? join(dir, '.npm'), npm_config_update_notifier: 'false' };
207
- const fail = (step, result) => {
208
- throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `${step} failed (exit ${result.code}): ${(result.stderr || result.stdout).trim().split('\n').slice(-3).join(' ')}`, 500);
555
+ const path = [npm.pathPrefix, env['PATH']].filter(Boolean).join(delimiter);
556
+ // No agent credentials for npm, lifecycle scripts or the smoke test; a per-upgrade cache that goes with the staging directory.
557
+ const npmEnv = childEnvironment({ PATH: path, npm_config_cache: join(work, '.npm'), npm_config_update_notifier: 'false' }, env);
558
+ const step = async (name, limitMs, command, args, cwd, stepEnv) => {
559
+ const remaining = STAGE_BUDGET_MS - (clock() - started);
560
+ if (remaining <= 0)
561
+ throw new SelfUpgradeError('SELF_UPGRADE_TIMEOUT', `the upgrade used its ${STAGE_BUDGET_MS / 60_000} minute budget before ${name}`, 504);
562
+ const timeoutMs = Math.min(limitMs, remaining);
563
+ const result = await run(command, args, { cwd, env: stepEnv, timeoutMs });
564
+ if (result.timedOut)
565
+ throw new SelfUpgradeError('SELF_UPGRADE_TIMEOUT', `${name} did not finish within ${Math.round(timeoutMs / 1000)} s`, 504);
566
+ return result;
567
+ };
568
+ const npmStep = (name, limitMs, args, cwd) => step(name, limitMs, npm.command, [...npm.args, ...args, ...NPM_NETWORK, '--loglevel=error'], cwd, npmEnv);
569
+ const fail = (name, result) => {
570
+ throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `${name} failed (exit ${result.code}): ${(result.stderr || result.stdout).trim().split('\n').slice(-3).join(' ')}`, 500);
209
571
  };
210
572
  // 1. The exact tarball: from an operator-provided directory, or the registry.
211
573
  const sourceDir = env['MAGNUS_AGENT_UPGRADE_SOURCE_DIR']?.trim();
@@ -216,7 +578,7 @@ export async function stageUpgrade(options) {
216
578
  throw new SelfUpgradeError('SELF_UPGRADE_SOURCE', `${tarball} does not exist`, 422);
217
579
  }
218
580
  else {
219
- const packed = await run(npm, ['pack', `${AGENT_PACKAGE}@${request.version}`, '--pack-destination', work, '--json', '--loglevel=error'], { cwd: work, env: npmEnv });
581
+ const packed = await npmStep('npm pack', STEP_TIMEOUT_MS.pack, ['pack', `${AGENT_PACKAGE}@${request.version}`, '--pack-destination', work, '--json'], work);
220
582
  if (packed.code !== 0)
221
583
  fail('npm pack', packed);
222
584
  tarball = join(work, tarballName(request.version));
@@ -234,18 +596,27 @@ export async function stageUpgrade(options) {
234
596
  name: `magnus-agent-${request.version}`, version: request.version, private: true, type: 'module',
235
597
  dependencies: { [AGENT_PACKAGE]: `file:${tarball}` }, ...(Object.keys(overrides).length ? { overrides } : {}), allowScripts: ALLOW_SCRIPTS,
236
598
  }, null, 2)}\n`);
237
- const installed = await run(npm, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: install, env: npmEnv });
599
+ const installed = await npmStep('npm install', STEP_TIMEOUT_MS.install, ['install', '--omit=dev', '--no-audit', '--no-fund'], install);
238
600
  if (installed.code !== 0)
239
601
  fail('npm install', installed);
240
- // 4. Registry signatures and provenance of everything installed.
602
+ // 4. This node must be able to run it: Node.js engines and the launcher protocol it needs.
603
+ const manifest = readJson(join(install, 'node_modules', ...AGENT_PACKAGE.split('/'), 'package.json')) ?? {};
604
+ const engine = engineProblem(manifest.engines?.node, options.nodeVersion ?? process.versions.node);
605
+ if (engine)
606
+ throw new SelfUpgradeError('SELF_UPGRADE_ENGINE', `${request.version} ${engine}`, 422);
607
+ const needs = Number.isInteger(manifest.magnusAgent?.minLauncher) ? manifest.magnusAgent.minLauncher : 1;
608
+ if (needs > (status.launcherProtocol ?? 0)) {
609
+ throw new SelfUpgradeError('SELF_UPGRADE_LAUNCHER', `${request.version} needs launcher protocol ${needs}; this node runs ${status.launcherProtocol ?? 'none'}. Run \`sudo magnus-agent install-service\` once to update it`, 422);
610
+ }
611
+ // 5. Registry signatures and provenance of everything installed.
241
612
  if ((env['MAGNUS_AGENT_UPGRADE_REQUIRE_PROVENANCE'] ?? 'true').trim().toLowerCase() !== 'false') {
242
- const audit = await run(npm, ['audit', 'signatures', '--omit=dev', '--loglevel=error'], { cwd: install, env: npmEnv });
613
+ const audit = await npmStep('npm audit signatures', STEP_TIMEOUT_MS.audit, ['audit', 'signatures', '--omit=dev'], install);
243
614
  if (audit.code !== 0)
244
615
  fail('npm audit signatures', audit);
245
616
  }
246
- // 5. The new code must at least start and report the requested version.
247
- const entry = join(install, 'node_modules', '@hivemagnus', 'magnus-agent', 'dist', 'index.js');
248
- const smoke = await run(process.execPath, [entry, '--version'], { cwd: install, env });
617
+ // 6. The new code must at least start and report the requested version.
618
+ const entry = join(install, ...ENTRY);
619
+ const smoke = await step('the installed agent', STEP_TIMEOUT_MS.smoke, process.execPath, [entry, '--version'], install, childEnvironment({ PATH: path }, env));
249
620
  if (smoke.code !== 0 || smoke.stdout.trim() !== request.version) {
250
621
  throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `the installed agent reports "${smoke.stdout.trim()}", not ${request.version}`, 500);
251
622
  }
@@ -254,16 +625,47 @@ export async function stageUpgrade(options) {
254
625
  adoptInstallOwner(dir, join(dir, 'versions'));
255
626
  adoptInstallOwner(dir, install);
256
627
  renameSync(install, target);
628
+ // 7. Record, then switch. A switch that fails records `failed`, so the launcher does not start a doomed trial.
257
629
  const now = (options.now ?? new Date()).toISOString();
258
- writeUpgradeState(dir, { state: 'staged', from, to: request.version, requestId: request.requestId, attempt: 0, maxAttempts: 2, reason: null, updatedAt: now });
259
- pointCurrentAt(dir, request.version);
630
+ const staged = { state: 'staged', from, to: request.version, requestId: request.requestId, attempt: 0, maxAttempts: 2, reason: null, updatedAt: now };
631
+ try {
632
+ withStateLock(dir, () => {
633
+ writeUpgradeState(dir, staged);
634
+ try {
635
+ pointCurrentAt(dir, request.version);
636
+ }
637
+ catch (error) {
638
+ const message = error instanceof Error ? error.message : String(error);
639
+ try {
640
+ writeUpgradeState(dir, { ...staged, state: 'failed', reason: `switching ./current failed: ${message}` });
641
+ }
642
+ catch { /* the launcher detects it */ }
643
+ throw new SelfUpgradeError('SELF_UPGRADE_FAILED', `switching ./current to ${request.version} failed: ${message}`, 500);
644
+ }
645
+ });
646
+ }
647
+ catch (error) {
648
+ try {
649
+ rmSync(target, { recursive: true, force: true });
650
+ }
651
+ catch { /* pruned later */ }
652
+ if (error instanceof SelfUpgradeError)
653
+ throw error;
654
+ const code = error.code;
655
+ throw new SelfUpgradeError(code === 'ELOCKED' ? 'SELF_UPGRADE_BUSY' : 'SELF_UPGRADE_FAILED', `recording the upgrade failed: ${error instanceof Error ? error.message : String(error)}`, code === 'ELOCKED' ? 409 : 500);
656
+ }
260
657
  return { from, to: request.version, integrity, requestId: request.requestId };
261
658
  }
262
659
  finally {
263
- rmSync(work, { recursive: true, force: true });
264
- adoptInstallOwner(dir, join(dir, 'staging'));
265
- adoptInstallOwner(dir, join(dir, '.npm'));
266
- staging = false;
660
+ try {
661
+ rmSync(work, { recursive: true, force: true });
662
+ }
663
+ catch { /* cleared at the next upgrade */ }
664
+ try {
665
+ adoptInstallOwner(dir, stagingRoot);
666
+ }
667
+ catch { /* reported by selfUpgradeStatus */ }
668
+ staging = null;
267
669
  }
268
670
  }
269
671
  //# sourceMappingURL=self-upgrade.js.map