@hivemagnus/magnus-server 1.1.7 → 1.1.8
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/.env.example +1 -0
- package/agent-version.json +1 -1
- package/dist/cli/agents.js +145 -25
- package/dist/cli/args.js +1 -1
- package/dist/cli/env-catalog.js +1 -0
- package/dist/config/features.js +2 -0
- package/dist/index.js +3 -0
- package/dist/openapi/spec.js +9 -7
- package/dist/routes/agent-upgrades.js +14 -3
- package/dist/routes/nodes.js +5 -2
- package/dist/sdk/node-api-client.js +82 -3
- package/dist/security/command-result-signature.js +22 -0
- package/dist/services/agent-upgrade-preflight.js +162 -0
- package/dist/services/agent-upgrade-readiness.js +203 -0
- package/dist/services/agent-upgrade-service.js +511 -115
- package/dist/services/command-fence-repair.js +72 -0
- package/dist/services/command-resource-leases.js +135 -8
- package/dist/services/resource-enrollment.js +10 -4
- package/package.json +3 -3
package/.env.example
CHANGED
|
@@ -197,5 +197,6 @@ CORS_ORIGIN=https://magnus.example.com:3000
|
|
|
197
197
|
# Server-driven agent upgrades (console "Upgrade agents", `magnus-server agents upgrade`).
|
|
198
198
|
# MAGNUS_AGENT_UPGRADES=true
|
|
199
199
|
# MAGNUS_AGENT_UPGRADE_CONCURRENCY=5
|
|
200
|
+
# MAGNUS_AGENT_UPGRADE_MAX_FAILURES=2
|
|
200
201
|
# The retired git-checkout self-update endpoints (source installs only).
|
|
201
202
|
# MAGNUS_LEGACY_GIT_SELF_UPDATE=false
|
package/agent-version.json
CHANGED
package/dist/cli/agents.js
CHANGED
|
@@ -1,25 +1,48 @@
|
|
|
1
|
-
/** `magnus-server agents upgrade`: the console's "Upgrade agents" action from a shell. */
|
|
1
|
+
/** `magnus-server agents upgrade`: the console's "Upgrade agents" action from a shell, and `agents doctor`, its check. */
|
|
2
|
+
import { upgradeRemedy } from '@hivemagnus/magnus-contracts';
|
|
2
3
|
import { UsageError } from './args.js';
|
|
3
4
|
export const AGENTS_USAGE = `Usage: magnus-server agents upgrade [options] [--env-file FILE]
|
|
5
|
+
magnus-server agents doctor [--nodes a,b] [--to X.Y.Z] [--heal] [--env-file FILE]
|
|
6
|
+
magnus-server agents fence NODE VALUE [--env-file FILE]
|
|
4
7
|
magnus-server agents stop RUN_ID [--env-file FILE]
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
first
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
upgrade: moves agents to the version bundled with this server (or --to). Every
|
|
10
|
+
selected node is checked first and blocked nodes are left out with the reason.
|
|
11
|
+
One canary goes first, then MAGNUS_AGENT_UPGRADE_CONCURRENCY at a time. Each
|
|
12
|
+
agent installs the exact version beside the running one and switches back by
|
|
13
|
+
itself if the new version does not report a heartbeat in time.
|
|
14
|
+
|
|
15
|
+
Only evidence about the release stops a run: an integrity or source failure at
|
|
16
|
+
once, a failure on the canary, ambiguous answers from canaries with nothing
|
|
17
|
+
confirmed, and after that MAGNUS_AGENT_UPGRADE_MAX_FAILURES reverts or failed
|
|
18
|
+
installs. A problem of one node (busy, enrollment, unreachable,
|
|
19
|
+
a lost reply) is recorded and the run goes on; transient ones are tried again at
|
|
20
|
+
the end. The summary lists each node needing attention, with what to do.
|
|
21
|
+
|
|
22
|
+
Exit status: 0 succeeded, 2 partial (some nodes need attention), 1 otherwise.
|
|
13
23
|
|
|
14
24
|
Options:
|
|
15
25
|
--to X.Y.Z Agent version (default: the one bundled with this server)
|
|
16
26
|
--integrity sha512-… npm integrity of that version (default: read from the registry)
|
|
17
27
|
--nodes a,b Only these nodes (names, host names or ids)
|
|
18
28
|
--canary NODE Upgrade this node first (default: the first eligible one)
|
|
29
|
+
--max-failures N Release failures that stop this run (default: MAGNUS_AGENT_UPGRADE_MAX_FAILURES)
|
|
19
30
|
--allow-prerelease Required for a prerelease version such as 1.2.0-rc.1
|
|
20
31
|
--retry-reverted Also offer nodes that already reverted this version
|
|
21
|
-
--retry RUN_ID Retry the
|
|
22
|
-
--
|
|
32
|
+
--retry RUN_ID Retry the nodes of a run that were not upgraded
|
|
33
|
+
--acknowledge-suspicion With --retry: also retry older agents after a run that stopped on their ambiguous answers
|
|
34
|
+
--dry-run Show the full per-node report and start nothing
|
|
35
|
+
|
|
36
|
+
doctor: the same read-only check for every node: what each agent reports about
|
|
37
|
+
its layout, launcher, disk, npm, ledger, fences and enrollment, and what to do
|
|
38
|
+
about each problem. --heal also repairs what the server owns: it moves a fence
|
|
39
|
+
generation up to what the agent signs that it holds, and settles leases stuck
|
|
40
|
+
unknown when the agent's ledger proves they never ran. Exit status 1 when a node
|
|
41
|
+
is blocked.
|
|
42
|
+
|
|
43
|
+
fence: moves the server's fence generation for a node to VALUE + 1. Only ever up,
|
|
44
|
+
and audited. For agents older than 1.1.8, which do not say what fence they hold:
|
|
45
|
+
read it from .magnus/command-receipts/fence-*.json on the node.
|
|
23
46
|
|
|
24
47
|
Ctrl-C asks the run to stop after the nodes in progress; a second Ctrl-C leaves
|
|
25
48
|
at once, and the run is then shown as interrupted.
|
|
@@ -38,9 +61,19 @@ export function parseAgentsArgs(args) {
|
|
|
38
61
|
throw new UsageError('agents stop takes one RUN_ID');
|
|
39
62
|
return { stop: id, dryRun: false };
|
|
40
63
|
}
|
|
41
|
-
if (sub
|
|
64
|
+
if (sub === 'fence') {
|
|
65
|
+
const [node, value, ...extra] = rest;
|
|
66
|
+
if (node === '--help' || node === '-h')
|
|
67
|
+
return 'help';
|
|
68
|
+
if (!node || !value || node.startsWith('--') || extra.length)
|
|
69
|
+
throw new UsageError('agents fence takes NODE and VALUE');
|
|
70
|
+
if (!/^[1-9][0-9]{0,18}$/.test(value))
|
|
71
|
+
throw new UsageError('agents fence: VALUE must be a positive decimal');
|
|
72
|
+
return { fence: { node, value }, dryRun: false };
|
|
73
|
+
}
|
|
74
|
+
if (sub !== 'upgrade' && sub !== 'doctor')
|
|
42
75
|
throw new UsageError(`unknown agents subcommand "${sub}"`);
|
|
43
|
-
const options = { dryRun: false };
|
|
76
|
+
const options = { dryRun: false, ...(sub === 'doctor' ? { doctor: true } : {}) };
|
|
44
77
|
for (let index = 0; index < rest.length; index++) {
|
|
45
78
|
const arg = rest[index];
|
|
46
79
|
const [name, inline] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
|
|
@@ -64,8 +97,18 @@ export function parseAgentsArgs(args) {
|
|
|
64
97
|
options.allowPrerelease = true;
|
|
65
98
|
else if (name === '--retry-reverted')
|
|
66
99
|
options.retryReverted = true;
|
|
100
|
+
else if (name === '--acknowledge-suspicion')
|
|
101
|
+
options.acknowledgeSuspicion = true;
|
|
67
102
|
else if (name === '--dry-run')
|
|
68
103
|
options.dryRun = true;
|
|
104
|
+
else if (name === '--heal' && sub === 'doctor')
|
|
105
|
+
options.heal = true;
|
|
106
|
+
else if (name === '--max-failures') {
|
|
107
|
+
const budget = Number(value());
|
|
108
|
+
if (!Number.isInteger(budget) || budget < 1 || budget > 1000)
|
|
109
|
+
throw new UsageError('--max-failures must be a whole number from 1 to 1000');
|
|
110
|
+
options.maxFailures = budget;
|
|
111
|
+
}
|
|
69
112
|
else if (name === '--help' || name === '-h')
|
|
70
113
|
return 'help';
|
|
71
114
|
else
|
|
@@ -77,6 +120,34 @@ export function parseAgentsArgs(args) {
|
|
|
77
120
|
throw new UsageError('--retry reuses the version, integrity and nodes of that run; drop --to, --integrity, --nodes and --dry-run');
|
|
78
121
|
return options;
|
|
79
122
|
}
|
|
123
|
+
const remedyLines = (item) => [
|
|
124
|
+
` ${item.name}: ${item.code}${item.unknownCode ? ' [unrecognised code]' : ''}${item.detail ? ` (${item.detail})` : ''}`,
|
|
125
|
+
` cause: ${item.cause}`,
|
|
126
|
+
` remedy: ${item.remedy}`,
|
|
127
|
+
];
|
|
128
|
+
/** What one node's check found, in the order someone would act on it. */
|
|
129
|
+
export function doctorLines(report) {
|
|
130
|
+
const lines = [`Target magnus-agent ${report.version}${report.release.verified ? '' : ` (not verified on the registry: ${report.release.reason ?? 'unknown'})`}`];
|
|
131
|
+
for (const node of report.nodes) {
|
|
132
|
+
const preflight = node.preflight;
|
|
133
|
+
const verdict = node.eligible ? 'ready' : node.code && upgradeRemedy(node.code).informational ? 'not applicable' : 'blocked';
|
|
134
|
+
const facts = preflight?.report ? [preflight.report.layout, preflight.report.disk ? `${Math.floor(preflight.report.disk.freeBytes / (1024 * 1024))} MiB free` : null,
|
|
135
|
+
preflight.report.npm?.version ? `npm ${preflight.report.npm.version}` : null].filter(Boolean).join(', ') : `preflight ${preflight?.status ?? 'not run'}${preflight?.detail ? `: ${preflight.detail}` : ''}`;
|
|
136
|
+
lines.push(`${node.name} ${node.version} ${verdict} (${facts})`);
|
|
137
|
+
if (!node.eligible && node.code)
|
|
138
|
+
lines.push(...remedyLines({ name: 'reason', code: node.code, detail: node.reason, cause: upgradeRemedy(node.code).cause, remedy: upgradeRemedy(node.code).remedy }));
|
|
139
|
+
for (const problem of preflight?.problems ?? []) {
|
|
140
|
+
if (problem.code === node.code)
|
|
141
|
+
continue;
|
|
142
|
+
const remedy = upgradeRemedy(problem.code);
|
|
143
|
+
lines.push(...remedyLines({ name: problem.blocking ? 'blocks' : 'note', code: problem.code, detail: problem.detail, cause: remedy.cause, remedy: remedy.remedy }));
|
|
144
|
+
}
|
|
145
|
+
for (const healed of preflight?.healed ?? [])
|
|
146
|
+
lines.push(` repaired: ${healed}`);
|
|
147
|
+
}
|
|
148
|
+
lines.push(`${report.eligible} ready, ${report.blocked} blocked, ${report.nodes.length - report.eligible - report.blocked} not applicable.`);
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
80
151
|
const nodeLine = (node) => ` ${node.name}: ${node.state}${node.detail ? ` (${node.code ? `${node.code}: ` : ''}${node.detail})` : node.code ? ` (${node.code})` : ''}`;
|
|
81
152
|
export async function commandAgents(args) {
|
|
82
153
|
const options = parseAgentsArgs(args);
|
|
@@ -93,6 +164,21 @@ export async function commandAgents(args) {
|
|
|
93
164
|
console.log(`Asked run ${run.id} to stop; it stops before its next batch.`);
|
|
94
165
|
return 0;
|
|
95
166
|
}
|
|
167
|
+
if (options.fence) {
|
|
168
|
+
const row = await getDb().node.findFirst({ where: { OR: [{ id: options.fence.node }, { name: options.fence.node }, { hostname: options.fence.node }] }, select: { id: true, name: true } });
|
|
169
|
+
if (!row) {
|
|
170
|
+
console.error(`Unknown node: ${options.fence.node}`);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
const { advanceCommandFence } = await import('../services/command-fence-repair.js');
|
|
174
|
+
const moved = await advanceCommandFence({ nodeId: row.id, resourceKey: `node:${row.id}`, highWater: options.fence.value, source: 'operator', actorId: 'system:magnus-server-cli' });
|
|
175
|
+
if (moved.status === 'rejected') {
|
|
176
|
+
console.error(`${moved.code}: node ${row.name} ${moved.detail}`);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
console.log(moved.status === 'advanced' ? `${row.name}: fence generation moved from ${moved.from} to ${moved.to}.` : `${row.name}: the fence generation is already past ${options.fence.value}; nothing changed.`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
96
182
|
const resolveNodes = async (names) => {
|
|
97
183
|
const rows = await getDb().node.findMany({
|
|
98
184
|
where: { OR: [{ id: { in: names } }, { name: { in: names } }, { hostname: { in: names } }] },
|
|
@@ -116,6 +202,12 @@ export async function commandAgents(args) {
|
|
|
116
202
|
canaryNodeId = resolved.byName(options.canary);
|
|
117
203
|
}
|
|
118
204
|
const deps = service.defaultDeps();
|
|
205
|
+
if (options.doctor) {
|
|
206
|
+
const report = await service.checkUpgrade({ version: options.to, integrity: options.integrity, nodeIds, heal: options.heal ?? false, deps, allowRetryReverted: options.retryReverted });
|
|
207
|
+
for (const line of doctorLines(report))
|
|
208
|
+
console.log(line);
|
|
209
|
+
return report.blocked ? 1 : 0;
|
|
210
|
+
}
|
|
119
211
|
const seen = new Map();
|
|
120
212
|
const save = deps.save;
|
|
121
213
|
deps.save = async (run) => {
|
|
@@ -131,27 +223,47 @@ export async function commandAgents(args) {
|
|
|
131
223
|
let started;
|
|
132
224
|
if (options.retry) {
|
|
133
225
|
started = await service.retryUpgradeRun(options.retry, { actorId: 'system:magnus-server-cli', deps, canaryNodeId,
|
|
134
|
-
allowRetryReverted: options.retryReverted, allowPrerelease: options.allowPrerelease });
|
|
226
|
+
allowRetryReverted: options.retryReverted, allowPrerelease: options.allowPrerelease, acknowledgeSuspicion: options.acknowledgeSuspicion, ...(options.maxFailures ? { maxFailures: options.maxFailures } : {}) });
|
|
135
227
|
console.log(`Retrying run ${options.retry} as ${started.run.id}: ${started.run.nodes.length} agent(s) to ${started.run.version}`);
|
|
136
228
|
}
|
|
137
229
|
else {
|
|
138
230
|
const release = await service.resolveAgentRelease({ version: options.to, integrity: options.integrity });
|
|
139
|
-
const candidates = await service.listUpgradeCandidates(release.version, nodeIds, { allowRetryReverted: options.retryReverted });
|
|
140
231
|
console.log(`magnus-agent ${release.version} (${release.integrity.slice(0, 19)}…)`);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
232
|
+
const showCandidates = (candidates) => {
|
|
233
|
+
for (const candidate of candidates) {
|
|
234
|
+
console.log(` ${candidate.eligible ? '→' : '·'} ${candidate.name.padEnd(28)} ${candidate.version.padEnd(12)} ${candidate.eligible ? 'will upgrade' : candidate.reason}`);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
145
237
|
if (options.dryRun) {
|
|
146
|
-
|
|
238
|
+
const report = await service.checkUpgrade({ version: release.version, integrity: release.integrity, nodeIds, deps, everyOnlineNode: false, allowRetryReverted: options.retryReverted });
|
|
239
|
+
showCandidates(report.nodes);
|
|
240
|
+
for (const node of report.nodes) {
|
|
241
|
+
if (!node.eligible && node.code && !upgradeRemedy(node.code).informational)
|
|
242
|
+
console.log(` ${node.name}: ${node.code}: ${node.remedy}`);
|
|
243
|
+
if (node.preflight?.status === 'unavailable')
|
|
244
|
+
console.log(` ${node.name}: preflight unavailable (${node.preflight.detail ?? 'older agent'}); heartbeat data was used`);
|
|
245
|
+
}
|
|
246
|
+
console.log(`Dry run: ${report.eligible} of ${report.nodes.length} agent(s) would be upgraded.`);
|
|
147
247
|
return 0;
|
|
148
248
|
}
|
|
149
|
-
|
|
249
|
+
try {
|
|
250
|
+
started = await service.startUpgradeRun({ version: release.version, integrity: release.integrity, nodeIds, canaryNodeId, allowPrerelease: options.allowPrerelease,
|
|
251
|
+
allowRetryReverted: options.retryReverted, actorId: 'system:magnus-server-cli', deps, ...(options.maxFailures ? { maxFailures: options.maxFailures } : {}) });
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (error.code !== 'NO_ELIGIBLE_AGENTS')
|
|
255
|
+
throw error;
|
|
256
|
+
const candidates = await service.listUpgradeCandidates(release.version, nodeIds, { allowRetryReverted: options.retryReverted });
|
|
257
|
+
showCandidates(candidates);
|
|
150
258
|
console.log('Nothing to upgrade.');
|
|
151
259
|
return candidates.some(candidate => candidate.reason !== 'already on this version') ? 1 : 0;
|
|
152
260
|
}
|
|
153
|
-
started
|
|
154
|
-
|
|
261
|
+
showCandidates(started.candidates);
|
|
262
|
+
for (const candidate of started.candidates)
|
|
263
|
+
for (const healed of candidate.preflight?.healed ?? [])
|
|
264
|
+
console.log(` ${candidate.name}: repaired: ${healed}`);
|
|
265
|
+
for (const warning of started.run.warnings ?? [])
|
|
266
|
+
console.warn(` ${warning.name}: ${warning.code}: ${warning.detail}`);
|
|
155
267
|
}
|
|
156
268
|
let interrupts = 0;
|
|
157
269
|
const onSigint = () => {
|
|
@@ -168,9 +280,17 @@ export async function commandAgents(args) {
|
|
|
168
280
|
for (const node of run.excluded ?? [])
|
|
169
281
|
if (options.nodes || options.retry)
|
|
170
282
|
console.log(` ${node.name}: not upgraded (${node.reason})`);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
283
|
+
if (run.suspectedRelease)
|
|
284
|
+
console.log(`Suspected release problem: ${run.suspectedRelease}`);
|
|
285
|
+
if (run.attention?.length) {
|
|
286
|
+
console.log(`Needs attention (${run.attention.length}):`);
|
|
287
|
+
for (const item of run.attention)
|
|
288
|
+
for (const line of remedyLines(item))
|
|
289
|
+
console.log(line);
|
|
290
|
+
}
|
|
291
|
+
console.log(`${run.status === 'succeeded' ? 'Done' : run.status === 'partial' ? 'Partial' : run.status === 'stopped' ? 'Stopped' : 'Failed'}: ${run.message ?? run.status}`);
|
|
292
|
+
console.log(`Run ${run.id}${run.nodes.every(node => node.state === 'confirmed') && !run.attention?.length ? '' : `; retry the rest with: magnus-server agents upgrade --retry ${run.id}`}`);
|
|
293
|
+
return run.status === 'succeeded' ? 0 : run.status === 'partial' ? 2 : 1;
|
|
174
294
|
}
|
|
175
295
|
finally {
|
|
176
296
|
process.off('SIGINT', onSigint);
|
package/dist/cli/args.js
CHANGED
|
@@ -11,7 +11,7 @@ Commands:
|
|
|
11
11
|
restore FILE Replace the database with a dump (stop the server first; asks unless --yes)
|
|
12
12
|
upgrade Upgrade a \`magnus init\` project (runs \`magnus upgrade\`; see --help there)
|
|
13
13
|
rollback Switch a \`magnus init\` project back to the previous version
|
|
14
|
-
agents Agent fleet commands: \`agents upgrade [--to X.Y.Z] [--nodes a,b] [--dry-run]\`
|
|
14
|
+
agents Agent fleet commands: \`agents upgrade [--to X.Y.Z] [--nodes a,b] [--dry-run]\`, \`agents doctor [--nodes a,b]\`
|
|
15
15
|
env-schema Print the documented environment variables
|
|
16
16
|
help Show this help
|
|
17
17
|
version Print the version
|
package/dist/cli/env-catalog.js
CHANGED
|
@@ -111,6 +111,7 @@ export const ENV_CATALOG = [
|
|
|
111
111
|
{ name: 'MAGNUS_UPDATE_REGISTRY', group: 'upgrades', default: 'https://registry.npmjs.org', description: 'npm registry used by the update check.' },
|
|
112
112
|
{ name: 'MAGNUS_AGENT_UPGRADES', group: 'upgrades', default: 'true', description: 'Allow server-driven agent upgrades (console action, API and `magnus-server agents upgrade`). Agents must also allow it (MAGNUS_AGENT_SELF_UPGRADE).' },
|
|
113
113
|
{ name: 'MAGNUS_AGENT_UPGRADE_CONCURRENCY', group: 'upgrades', default: '5', description: 'Agents upgraded in parallel after the first (canary) agent confirms. Maximum 50.' },
|
|
114
|
+
{ name: 'MAGNUS_AGENT_UPGRADE_MAX_FAILURES', group: 'upgrades', default: '2', description: 'Failures pointing at the release (revert, failed install, trial that never confirms) that stop an agent upgrade run after its canary. Problems of one node never stop a run. A run can override it.' },
|
|
114
115
|
{ name: 'MAGNUS_LEGACY_GIT_SELF_UPDATE', group: 'upgrades', default: 'false', description: 'true: keep the retired git-checkout self-update endpoints (/api/v1/self-update/apply and /restart) for source installs. npm installs use `magnus upgrade`.' },
|
|
115
116
|
{ name: 'NODE_EXTRA_CA_CERTS', group: 'upgrades', description: 'Extra CA bundle (PEM) trusted when the server calls agents, for agent certificates from a private CA. Node reads it at process start: set it in the unit; `magnus-server agents` also honours it from --env-file.' },
|
|
116
117
|
{ name: 'MAGNUS_PROJECT_DIR', group: 'upgrades', description: 'Managed project created by `magnus init` (holds magnus-project.json). `magnus-server upgrade` finds it from the install path when unset.' },
|
package/dist/config/features.js
CHANGED
|
@@ -24,6 +24,7 @@ export function envSwitch(value, fallback) {
|
|
|
24
24
|
}
|
|
25
25
|
export function upgradeSettings(env = process.env) {
|
|
26
26
|
const concurrency = Number.parseInt(env['MAGNUS_AGENT_UPGRADE_CONCURRENCY'] ?? '', 10);
|
|
27
|
+
const maxFailures = Number.parseInt(env['MAGNUS_AGENT_UPGRADE_MAX_FAILURES'] ?? '', 10);
|
|
27
28
|
return {
|
|
28
29
|
autoMigrate: envSwitch(env['MAGNUS_AUTO_MIGRATE'], false),
|
|
29
30
|
autoMigrateBackup: envSwitch(env['MAGNUS_AUTO_MIGRATE_BACKUP'], true),
|
|
@@ -32,6 +33,7 @@ export function upgradeSettings(env = process.env) {
|
|
|
32
33
|
updateRegistry: (env['MAGNUS_UPDATE_REGISTRY']?.trim() || 'https://registry.npmjs.org').replace(/\/+$/, ''),
|
|
33
34
|
agentUpgrades: envSwitch(env['MAGNUS_AGENT_UPGRADES'], true),
|
|
34
35
|
agentUpgradeConcurrency: Number.isInteger(concurrency) && concurrency > 0 ? Math.min(concurrency, 50) : 5,
|
|
36
|
+
agentUpgradeMaxFailures: Number.isInteger(maxFailures) && maxFailures > 0 ? Math.min(maxFailures, 1000) : 2,
|
|
35
37
|
legacyGitSelfUpdate: envSwitch(env['MAGNUS_LEGACY_GIT_SELF_UPDATE'], false),
|
|
36
38
|
};
|
|
37
39
|
}
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ const { startRolloutReconciler, stopRolloutReconciler } = await import('./servic
|
|
|
24
24
|
const { startApplicationProbes, stopApplicationProbes } = await import('./services/application-probes.js');
|
|
25
25
|
const { startNodeIssueMonitor } = await import('./services/node-issue-monitor.js');
|
|
26
26
|
const { stopNodeIssueMonitor } = await import('./services/node-issue-monitor.js');
|
|
27
|
+
const { startUpgradeReadinessMonitor, stopUpgradeReadinessMonitor } = await import('./services/agent-upgrade-readiness.js');
|
|
27
28
|
const { startLogRetention } = await import('./services/log-retention.js');
|
|
28
29
|
const { stopLogRetention } = await import('./services/log-retention.js');
|
|
29
30
|
const { startHealthRetention } = await import('./services/health-retention.js');
|
|
@@ -58,6 +59,7 @@ const { CONTROL_API_VERSION } = await import('./config/version.js');
|
|
|
58
59
|
function startBackgroundServices(listenPort) {
|
|
59
60
|
writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort, host: listener.hostname });
|
|
60
61
|
startNodeIssueMonitor();
|
|
62
|
+
startUpgradeReadinessMonitor();
|
|
61
63
|
startApplicationProbes();
|
|
62
64
|
startLogRetention();
|
|
63
65
|
startHealthRetention();
|
|
@@ -130,6 +132,7 @@ async function shutdown(signal) {
|
|
|
130
132
|
await Promise.race([(async () => {
|
|
131
133
|
stopRolloutReconciler();
|
|
132
134
|
stopNodeIssueMonitor();
|
|
135
|
+
stopUpgradeReadinessMonitor();
|
|
133
136
|
stopApplicationProbes();
|
|
134
137
|
stopLogRetention();
|
|
135
138
|
stopHealthRetention();
|
package/dist/openapi/spec.js
CHANGED
|
@@ -655,7 +655,7 @@ const paths = {
|
|
|
655
655
|
get: {
|
|
656
656
|
tags: ['Nodes'],
|
|
657
657
|
summary: 'List nodes',
|
|
658
|
-
description: 'Returns all fleet nodes with agent info and health metadata. Supports pagination and status filtering.',
|
|
658
|
+
description: 'Returns all fleet nodes with agent info and health metadata. Supports pagination and status filtering. Each node has `upgradeReadiness`: `status` (ok, not-ready, or unknown for agents that do not report it) and `problems` with a code, a detail, a cause and a remedy, such as an enrollment stuck draining, a marker the server does not share, a fence ahead of the server, an unresolved ledger, low disk or missing npm.',
|
|
659
659
|
security: apiSecretAuth,
|
|
660
660
|
parameters: [
|
|
661
661
|
qp('page', 'Page number (1-based)', { type: 'integer' }),
|
|
@@ -2156,7 +2156,7 @@ const paths = {
|
|
|
2156
2156
|
get: {
|
|
2157
2157
|
tags: ['Agent upgrades'],
|
|
2158
2158
|
summary: 'Which agents can take a version',
|
|
2159
|
-
description: 'For every node: its agent version and whether it can upgrade itself to `version` (default: the agent version bundled with this server) now, or why not: offline, not set up with `magnus-agent install-service`, MAGNUS_AGENT_SELF_UPGRADE=false, another major version, already current, already reverted this version, a closed release admission fence, a resource enrollment that is not complete (for example "stuck in draining since <date>"), held participant work or command leases, or an agent whose sticky managed-enrollment marker the server does not share. `release.verified` is false when the registry does not have the version.',
|
|
2159
|
+
description: 'For every node: its agent version and whether it can upgrade itself to `version` (default: the agent version bundled with this server) now, or why not: offline, not set up with `magnus-agent install-service`, MAGNUS_AGENT_SELF_UPGRADE=false, another major version, already current, already reverted this version, a closed release admission fence, a resource enrollment that is not complete (for example "stuck in draining since <date>"), held participant work or command leases, or an agent whose sticky managed-enrollment marker the server does not share. Each blocked node has a `code` from the upgrade problem catalogue. `preflight` is the latest result of the agent check for this version, when one is recent, and only for ADMIN. `release.verified` is false when the registry does not have the version.',
|
|
2160
2160
|
security: apiSecretAuth,
|
|
2161
2161
|
parameters: [qp('version', 'Target agent version (X.Y.Z)'), qp('allowRetryReverted', '`true` to offer nodes that already reverted this version')],
|
|
2162
2162
|
responses: { 200: success('Eligibility', { type: 'object', properties: {
|
|
@@ -2164,7 +2164,7 @@ const paths = {
|
|
|
2164
2164
|
prerelease: { type: 'boolean' },
|
|
2165
2165
|
release: { type: 'object', properties: { verified: { type: 'boolean' }, integrity: { type: ['string', 'null'] }, reason: { type: ['string', 'null'] } } },
|
|
2166
2166
|
nodes: { type: 'array', items: { type: 'object', properties: { nodeId: { type: 'string' }, name: { type: 'string' },
|
|
2167
|
-
version: { type: 'string' }, eligible: { type: 'boolean' }, reason: { type: ['string', 'null'] } } } },
|
|
2167
|
+
version: { type: 'string' }, eligible: { type: 'boolean' }, reason: { type: ['string', 'null'] }, code: { type: ['string', 'null'] }, preflight: { type: ['object', 'null'], description: 'status: ok, warning, blocked, unavailable (older agent) or unreachable; problems: code, detail, blocking' } } } },
|
|
2168
2168
|
latestRun: { type: ['object', 'null'] },
|
|
2169
2169
|
} }), 400: err400, 401: err401 },
|
|
2170
2170
|
},
|
|
@@ -2173,7 +2173,7 @@ const paths = {
|
|
|
2173
2173
|
post: {
|
|
2174
2174
|
tags: ['Agent upgrades'],
|
|
2175
2175
|
summary: 'Upgrade agents',
|
|
2176
|
-
description: 'Starts an upgrade run (ADMIN, MAGNUS_AGENT_UPGRADES=true): one canary, then MAGNUS_AGENT_UPGRADE_CONCURRENCY agents at a time. Each agent installs the exact version and npm integrity and switches back by itself if the new version does not report a heartbeat in time.
|
|
2176
|
+
description: 'Starts an upgrade run (ADMIN, MAGNUS_AGENT_UPGRADES=true): every selected node is checked first, then one canary, then MAGNUS_AGENT_UPGRADE_CONCURRENCY agents at a time. Each agent installs the exact version and npm integrity and switches back by itself if the new version does not report a heartbeat in time. With `dryRun` it returns 200 with the full per-node report and starts nothing. Only evidence about the release stops a run: an integrity or source failure at once, a release failure on the canary, `maxFailures` ambiguous canaries with nothing confirmed (at least two, reported as suspect but unproven in `suspectedRelease`; nodes that cannot report their fence go last and do not count while another may still confirm), and after the canary `maxFailures` reverts, failed installs or trials that never confirm. A problem of one node is recorded and the run goes on: a refused canary hands the role to the next node, transient refusals are tried again at the end of the wave, and a node whose reply was lost is watched by request id (`watching`, then `ambiguous`) without holding a batch slot past its window. The final status is `succeeded`, `partial` (node-level problems, nothing against the release), `failed` (release evidence), `stopped` or `interrupted`, and `attention` lists each node with a code, a cause and a remedy; a run is `succeeded` only if every selected node is confirmed or has an informational reason. `warnings` lists what the check found that does not stop a node. Only one run at a time. Selected nodes that are not eligible are listed in `excluded`; unknown node ids are a 400.',
|
|
2177
2177
|
security: apiSecretAuth,
|
|
2178
2178
|
requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
|
|
2179
2179
|
version: { type: 'string', description: 'Agent version (default: the one bundled with this server)' },
|
|
@@ -2182,8 +2182,10 @@ const paths = {
|
|
|
2182
2182
|
canaryNodeId: { type: 'string', description: 'Upgrade this node first' },
|
|
2183
2183
|
allowPrerelease: { type: 'boolean', description: 'Required for a prerelease version' },
|
|
2184
2184
|
allowRetryReverted: { type: 'boolean', description: 'Also upgrade nodes that already reverted this version' },
|
|
2185
|
+
dryRun: { type: 'boolean', description: 'Report on every selected node and start nothing' },
|
|
2186
|
+
maxFailures: { type: 'integer', minimum: 1, maximum: 1000, description: 'Release failures that stop this run (default MAGNUS_AGENT_UPGRADE_MAX_FAILURES)' },
|
|
2185
2187
|
} } } } },
|
|
2186
|
-
responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 409: err409 },
|
|
2188
|
+
responses: { 200: success('Dry run report'), 202: success('Run started'), 400: err400, 401: err401, 403: err403, 409: err409 },
|
|
2187
2189
|
},
|
|
2188
2190
|
},
|
|
2189
2191
|
'/agents/upgrade/runs/latest': {
|
|
@@ -2218,11 +2220,11 @@ const paths = {
|
|
|
2218
2220
|
post: {
|
|
2219
2221
|
tags: ['Agent upgrades'],
|
|
2220
2222
|
summary: 'Retry an upgrade run',
|
|
2221
|
-
description: 'ADMIN. Starts a new run with the same version and integrity for the failed, refused, skipped and interrupted
|
|
2223
|
+
description: 'ADMIN. Starts a new run with the same version and integrity for the nodes of a finished run that were not upgraded: failed, refused, skipped, ambiguous and interrupted ones, and those the check held back (reverted nodes only with allowRetryReverted).',
|
|
2222
2224
|
security: apiSecretAuth,
|
|
2223
2225
|
parameters: [pp('id', 'Run ID')],
|
|
2224
2226
|
requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {
|
|
2225
|
-
canaryNodeId: { type: 'string' }, allowRetryReverted: { type: 'boolean' },
|
|
2227
|
+
canaryNodeId: { type: 'string' }, allowRetryReverted: { type: 'boolean' }, maxFailures: { type: 'integer', minimum: 1, maximum: 1000 }, acknowledgeSuspicion: { type: 'boolean', description: 'After a run that stopped on ambiguous answers from older agents, include older agents again; without this they are held back with RETRY_HELD_SUSPECTED' },
|
|
2226
2228
|
} } } } },
|
|
2227
2229
|
responses: { 202: success('Run started'), 400: err400, 401: err401, 403: err403, 404: err404, 409: err409 },
|
|
2228
2230
|
},
|
|
@@ -4,12 +4,13 @@ import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
|
4
4
|
import { HttpError } from '../middleware/error-handler.js';
|
|
5
5
|
import { upgradeSettings } from '../config/features.js';
|
|
6
6
|
import { AGENT_VERSION } from '../config/version.js';
|
|
7
|
-
import { getUpgradeRun, isPrerelease, latestUpgradeRun, listUpgradeCandidates, retryUpgradeRun, startUpgradeRun, stopUpgradeRun, verifyAgentRelease } from '../services/agent-upgrade-service.js';
|
|
7
|
+
import { checkUpgrade, getUpgradeRun, isPrerelease, latestUpgradeRun, listUpgradeCandidates, retryUpgradeRun, startUpgradeRun, stopUpgradeRun, verifyAgentRelease, withCachedPreflight } from '../services/agent-upgrade-service.js';
|
|
8
8
|
/** /api/v1/agents/upgrade: which agents can take a version, and fleet upgrade runs. */
|
|
9
9
|
export const agentUpgradesRouter = new Hono();
|
|
10
10
|
agentUpgradesRouter.use('*', authMiddleware);
|
|
11
11
|
const version = z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
|
|
12
12
|
const nodeId = z.string().min(1).max(128);
|
|
13
|
+
const maxFailures = z.number().int().min(1).max(1000);
|
|
13
14
|
agentUpgradesRouter.get('/eligibility', async (c) => {
|
|
14
15
|
const target = version.safeParse(c.req.query('version') ?? AGENT_VERSION);
|
|
15
16
|
if (!target.success)
|
|
@@ -21,7 +22,9 @@ agentUpgradesRouter.get('/eligibility', async (c) => {
|
|
|
21
22
|
return c.json({ success: true, data: {
|
|
22
23
|
version: target.data, serverVersion: AGENT_VERSION, upgradesEnabled: upgradeSettings().agentUpgrades,
|
|
23
24
|
disabledReason: upgradeSettings().agentUpgrades ? null : 'MAGNUS_AGENT_UPGRADES=false on the server',
|
|
24
|
-
prerelease: isPrerelease(target.data), release,
|
|
25
|
+
prerelease: isPrerelease(target.data), release,
|
|
26
|
+
// Reports hold paths, fences and command ids: only the role that can run upgrades sees them.
|
|
27
|
+
nodes: getUser(c).role === 'ADMIN' ? withCachedPreflight(nodes, target.data) : nodes.map(({ preflight: _preflight, ...node }) => node), latestRun,
|
|
25
28
|
} });
|
|
26
29
|
});
|
|
27
30
|
const startSchema = z.object({
|
|
@@ -31,6 +34,9 @@ const startSchema = z.object({
|
|
|
31
34
|
canaryNodeId: nodeId.optional(),
|
|
32
35
|
allowPrerelease: z.boolean().optional(),
|
|
33
36
|
allowRetryReverted: z.boolean().optional(),
|
|
37
|
+
/** Report on every selected node and start nothing: the console's Check action. */
|
|
38
|
+
dryRun: z.boolean().optional(),
|
|
39
|
+
maxFailures: maxFailures.optional(),
|
|
34
40
|
}).strict();
|
|
35
41
|
const parseBody = async (schema, raw) => {
|
|
36
42
|
const parsed = schema.safeParse(raw);
|
|
@@ -40,7 +46,10 @@ const parseBody = async (schema, raw) => {
|
|
|
40
46
|
};
|
|
41
47
|
agentUpgradesRouter.post('/', requireRole('ADMIN'), async (c) => {
|
|
42
48
|
const body = await parseBody(startSchema, await c.req.json().catch(() => ({})));
|
|
43
|
-
const {
|
|
49
|
+
const { dryRun, ...options } = body;
|
|
50
|
+
if (dryRun)
|
|
51
|
+
return c.json({ success: true, data: { dryRun: true, ...await checkUpgrade(options) } });
|
|
52
|
+
const { run } = await startUpgradeRun({ ...options, actorId: getUser(c).userId });
|
|
44
53
|
return c.json({ success: true, data: run }, 202);
|
|
45
54
|
});
|
|
46
55
|
agentUpgradesRouter.get('/runs/latest', async (c) => c.json({ success: true, data: await latestUpgradeRun() }));
|
|
@@ -54,6 +63,8 @@ agentUpgradesRouter.post('/runs/:id/stop', requireRole('ADMIN'), async (c) => c.
|
|
|
54
63
|
const retrySchema = z.object({
|
|
55
64
|
canaryNodeId: nodeId.optional(),
|
|
56
65
|
allowRetryReverted: z.boolean().optional(),
|
|
66
|
+
maxFailures: maxFailures.optional(),
|
|
67
|
+
acknowledgeSuspicion: z.boolean().optional(),
|
|
57
68
|
}).strict();
|
|
58
69
|
agentUpgradesRouter.post('/runs/:id/retry', requireRole('ADMIN'), async (c) => {
|
|
59
70
|
const body = await parseBody(retrySchema, await c.req.json().catch(() => ({})));
|
package/dist/routes/nodes.js
CHANGED
|
@@ -17,6 +17,7 @@ import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
|
17
17
|
import { getNodeManagerSecret } from '../utils/env.js';
|
|
18
18
|
import { ensureControlHostNode } from '../services/control-host-node.js';
|
|
19
19
|
import { withReadRetry } from '../db/read-retry.js';
|
|
20
|
+
import { readinessOfNodes } from '../services/agent-upgrade-readiness.js';
|
|
20
21
|
import { parseTailscaleNodeKeyPosture, summarizeTailscaleNodeKeys, TAILSCALE_RENEWAL_WARNING_DAYS, } from '../services/tailscale-node-key-service.js';
|
|
21
22
|
function parseSystemUpdatePosture(value) {
|
|
22
23
|
try {
|
|
@@ -116,9 +117,10 @@ nodesRouter.get('/', async (c) => {
|
|
|
116
117
|
]));
|
|
117
118
|
// Strip token from API response — never expose node secrets to the frontend
|
|
118
119
|
const safeNodes = nodes.map(({ token: _token, ...rest }) => rest);
|
|
120
|
+
const readiness = await readinessOfNodes(safeNodes);
|
|
119
121
|
return c.json({
|
|
120
122
|
success: true,
|
|
121
|
-
data: safeNodes,
|
|
123
|
+
data: safeNodes.map(node => ({ ...node, upgradeReadiness: readiness.get(node.id) ?? null })),
|
|
122
124
|
meta: { total, page, pageSize, hasMore: page * pageSize < total },
|
|
123
125
|
});
|
|
124
126
|
});
|
|
@@ -494,7 +496,8 @@ nodesRouter.get('/:nodeId', async (c) => {
|
|
|
494
496
|
if (!node)
|
|
495
497
|
throw new HttpError(404, 'NOT_FOUND', 'Node not found');
|
|
496
498
|
const { token: _token, ...safeNode } = node;
|
|
497
|
-
|
|
499
|
+
const readiness = await readinessOfNodes([safeNode]);
|
|
500
|
+
return c.json({ success: true, data: { ...safeNode, upgradeReadiness: readiness.get(safeNode.id) ?? null } });
|
|
498
501
|
});
|
|
499
502
|
// ── Register a new node ──
|
|
500
503
|
nodesRouter.post('/register', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
@@ -5,11 +5,13 @@ import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, comman
|
|
|
5
5
|
import { commandReconciliationSchema } from '../schemas/command-reconciliation.js';
|
|
6
6
|
import { admitResourceCommand, markResourceDispatched, failResourceCommand, settleResourceResult, settleResourceReconciliation } from '../services/command-resource-leases.js';
|
|
7
7
|
import { commandFenceSchema, commandResultSchema } from '../schemas/node-responses.js';
|
|
8
|
+
import { advanceCommandFence } from '../services/command-fence-repair.js';
|
|
8
9
|
import { commandRequestDigest } from '../security/command-request.js';
|
|
9
10
|
import ky, { HTTPError } from 'ky';
|
|
10
11
|
import { HttpError } from '../middleware/error-handler.js';
|
|
11
12
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
12
13
|
import { z } from 'zod';
|
|
14
|
+
import { agentUpgradePreflightSchema } from '@hivemagnus/magnus-contracts';
|
|
13
15
|
import { RESULT_SIGNING_PREVIOUS_SECRET, RESULT_SIGNING_SECRET } from '../utils/env.js';
|
|
14
16
|
import { correlateSkillPublicationNoEffect } from '../services/skill-publication-settlement.js';
|
|
15
17
|
import { commandResultSigningPayload, verifyCommandResultSignature, } from '../security/command-result-signature.js';
|
|
@@ -125,10 +127,52 @@ export function isConnectError(error) {
|
|
|
125
127
|
function errorText(error) {
|
|
126
128
|
return (error instanceof Error ? error.message : String(error)).slice(0, 1000);
|
|
127
129
|
}
|
|
130
|
+
const certainRefusalOf = (error) => error?.certainRefusal;
|
|
131
|
+
/** The signed refusal in an agent's error reply. An older agent, or a reply without one, says nothing about execution. */
|
|
132
|
+
export function parseAgentRefusal(error) {
|
|
133
|
+
if (!(error instanceof HTTPError))
|
|
134
|
+
return null;
|
|
135
|
+
const body = (error.data ?? null);
|
|
136
|
+
const refusal = body?.error?.refusal;
|
|
137
|
+
if (body?.error?.executed !== false || !refusal || typeof refusal.commandId !== 'string' || typeof refusal.requestSha256 !== 'string'
|
|
138
|
+
|| typeof refusal.code !== 'string' || typeof refusal.signature !== 'string' || !(refusal.highWater === null || typeof refusal.highWater === 'string')
|
|
139
|
+
|| typeof refusal.nodeFqdn !== 'string' || typeof refusal.leaseId !== 'string')
|
|
140
|
+
return null;
|
|
141
|
+
return { commandId: refusal.commandId, requestSha256: refusal.requestSha256, code: refusal.code, highWater: refusal.highWater, nodeFqdn: refusal.nodeFqdn, leaseId: refusal.leaseId, signature: refusal.signature,
|
|
142
|
+
message: typeof body.error.message === 'string' ? body.error.message.slice(0, 1000) : refusal.code };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Close the lease of a dispatch that threw. A verified refusal settles it as
|
|
146
|
+
* not-dispatched, and a STALE_FENCE moves the server's fence past the agent's
|
|
147
|
+
* high-water so the next request can pass. Everything else stays `unknown`.
|
|
148
|
+
*/
|
|
149
|
+
async function settleFailedDispatch(admission, actorId, error) {
|
|
150
|
+
const refusal = parseAgentRefusal(error);
|
|
151
|
+
const settled = await failResourceCommand(admission, refusal ?? undefined);
|
|
152
|
+
if (!refusal || !settled)
|
|
153
|
+
return;
|
|
154
|
+
let code = refusal.code;
|
|
155
|
+
let { message } = refusal;
|
|
156
|
+
const resourceKey = admission.payload.coordination?.resourceKey;
|
|
157
|
+
if (code === 'STALE_FENCE' && refusal.highWater && resourceKey) {
|
|
158
|
+
try {
|
|
159
|
+
const advanced = await advanceCommandFence({ nodeId: admission.nodeId, resourceKey, highWater: refusal.highWater, source: 'stale-fence-reply', actorId });
|
|
160
|
+
if (advanced.status === 'rejected')
|
|
161
|
+
({ code, detail: message } = advanced);
|
|
162
|
+
}
|
|
163
|
+
catch (repairError) {
|
|
164
|
+
console.warn(`[resource-leases] fence of ${admission.nodeId} could not be advanced: ${repairError instanceof Error ? repairError.message : String(repairError)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
Object.assign(error, { certainRefusal: { code, message } });
|
|
168
|
+
}
|
|
128
169
|
/** Sort an upgradeAgent failure by whether the request can have reached the agent. */
|
|
129
170
|
export function classifyAgentRequestError(error, dispatched) {
|
|
171
|
+
const refused = certainRefusalOf(error);
|
|
172
|
+
if (refused)
|
|
173
|
+
return new NotDispatchedError(refused.code, refused.message, error);
|
|
130
174
|
if (!dispatched)
|
|
131
|
-
return error instanceof NotDispatchedError ? error : new NotDispatchedError(error instanceof HttpError ? error.code : error instanceof AgentResponseError && error.code ? error.code : 'NOT_DISPATCHED', errorText(error), error);
|
|
175
|
+
return error instanceof NotDispatchedError ? error : new NotDispatchedError(error instanceof HttpError ? error.code : error instanceof AgentResponseError && error.code ? error.code : isConnectError(error) ? 'AGENT_UNREACHABLE' : 'NOT_DISPATCHED', errorText(error), error);
|
|
132
176
|
if (error instanceof AgentResponseError)
|
|
133
177
|
return error;
|
|
134
178
|
if (error instanceof HTTPError)
|
|
@@ -225,7 +269,7 @@ export class NodeApiClient {
|
|
|
225
269
|
acknowledgedAt: new Date() } };
|
|
226
270
|
}
|
|
227
271
|
catch (error) {
|
|
228
|
-
await
|
|
272
|
+
await settleFailedDispatch(admission, this.resourceContext.actorId, error);
|
|
229
273
|
throw error;
|
|
230
274
|
}
|
|
231
275
|
}
|
|
@@ -324,7 +368,7 @@ export class NodeApiClient {
|
|
|
324
368
|
}
|
|
325
369
|
catch (error) {
|
|
326
370
|
if (admission)
|
|
327
|
-
await
|
|
371
|
+
await settleFailedDispatch(admission, this.resourceContext.actorId, error);
|
|
328
372
|
throw error;
|
|
329
373
|
}
|
|
330
374
|
}
|
|
@@ -506,6 +550,41 @@ export class NodeApiClient {
|
|
|
506
550
|
throw classifyAgentRequestError(error, dispatched);
|
|
507
551
|
}
|
|
508
552
|
}
|
|
553
|
+
/**
|
|
554
|
+
* The agent's read-only upgrade report: its GET needs no lease or claim.
|
|
555
|
+
* `unavailable` is an agent older than 1.1.8 (404) or a report that does not
|
|
556
|
+
* parse; neither says anything against the node.
|
|
557
|
+
*/
|
|
558
|
+
async getUpgradePreflight(query, timeoutMs = 5_000) {
|
|
559
|
+
let body;
|
|
560
|
+
try {
|
|
561
|
+
body = await this.api.get('agent-upgrade/preflight', { timeout: timeoutMs, retry: 0,
|
|
562
|
+
searchParams: { ...(query.target ? { target: query.target } : {}), ...(query.engines ? { engines: query.engines } : {}), ...(query.nonce ? { nonce: query.nonce } : {}) } }).json();
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (error instanceof HTTPError && error.response.status === 404)
|
|
566
|
+
return { status: 'unavailable', detail: 'this agent has no preflight check' };
|
|
567
|
+
if (error instanceof SyntaxError)
|
|
568
|
+
return { status: 'unavailable', detail: 'the preflight answer is not JSON' };
|
|
569
|
+
return { status: 'unreachable', detail: errorText(error) };
|
|
570
|
+
}
|
|
571
|
+
const parsed = agentUpgradePreflightSchema.safeParse(body?.data);
|
|
572
|
+
return parsed.success ? { status: 'ok', report: parsed.data } : { status: 'unavailable', detail: 'the preflight answer does not parse' };
|
|
573
|
+
}
|
|
574
|
+
/** Signed account of what the agent's ledger holds for a command; null from an agent without this route. */
|
|
575
|
+
async getCommandTrace(commandId, nonce, resourceKey, timeoutMs = 5_000) {
|
|
576
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(commandId))
|
|
577
|
+
throw new Error('Invalid command identifier');
|
|
578
|
+
try {
|
|
579
|
+
const response = await this.api.get(`commands/receipts/${encodeURIComponent(commandId)}/trace`, { timeout: timeoutMs, retry: 0, searchParams: { nonce, resourceKey } }).json();
|
|
580
|
+
return response.success ? response.data : null;
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
if (error instanceof HTTPError && error.response.status === 404)
|
|
584
|
+
return null;
|
|
585
|
+
throw error;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
509
588
|
// ── Ping ──
|
|
510
589
|
async ping() {
|
|
511
590
|
return this.api.get('ping').json();
|