@hivemagnus/magnus-server 1.1.5 → 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 +104 -6
- package/dist/security/command-result-signature.js +22 -0
- package/dist/security/managed-http.js +16 -2
- 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 +512 -116
- package/dist/services/command-fence-repair.js +72 -0
- package/dist/services/command-resource-leases.js +135 -8
- package/dist/services/hermes-cli-parser.js +67 -8
- package/dist/services/hermes-gateway-status.js +67 -0
- package/dist/services/hermes-service.js +6 -1
- package/dist/services/orchestra-extras-service.js +4 -3
- package/dist/services/resource-enrollment.js +10 -4
- package/dist/services/update-check-service.js +1 -1
- package/package.json +3 -3
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { MANAGED_CLAIM_HEADER, httpIdentity, httpReceiptBody, managedHttpRequestSchema } from '../security/managed-http.js';
|
|
2
|
+
import { MANAGED_CLAIM_HEADER, credentialMutationIdentity, httpIdentity, httpReceiptBody, managedHttpRequestSchema } from '../security/managed-http.js';
|
|
3
3
|
import { nativeCompletionDescriptor, nativeCompletionResourceKey } from '../security/native-completion-registry.js';
|
|
4
4
|
import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, commandCompletionDescriptor } from '../security/command-completion-registry.js';
|
|
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,13 +269,32 @@ 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
|
}
|
|
232
276
|
async mutate(method, path, body, timeout = this.defaultTimeout) {
|
|
233
277
|
return (await this.mutateWithEvidence(method, path, body, timeout)).response;
|
|
234
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* A body holding a credential never goes through the digest in mutateWithEvidence.
|
|
281
|
+
* Enrolled nodes refuse it exactly as they refuse any mutation without a reviewed
|
|
282
|
+
* completion boundary; other nodes receive the same direct POST as before.
|
|
283
|
+
*/
|
|
284
|
+
async mutateWithCredential(path, body, timeout = this.defaultTimeout) {
|
|
285
|
+
if (!this.resourceContext)
|
|
286
|
+
throw new Error('Managed mutation requires canonical resource node identity');
|
|
287
|
+
const nativePath = '/' + path;
|
|
288
|
+
if (nativeCompletionDescriptor('POST', nativePath))
|
|
289
|
+
throw new Error('A managed native path cannot carry an unbound credential mutation');
|
|
290
|
+
const identity = credentialMutationIdentity('native-' + randomUUID(), 'POST', nativePath, Math.min(3600, Math.ceil(timeout / 1000)));
|
|
291
|
+
const admission = await admitResourceCommand(this.resourceContext, identity);
|
|
292
|
+
if (admission) {
|
|
293
|
+
await failResourceCommand(admission);
|
|
294
|
+
throw new Error('This enrolled-node mutation lacks a reviewed completion boundary; not dispatched');
|
|
295
|
+
}
|
|
296
|
+
return this.api(path, { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, timeout, retry: 0 }).json();
|
|
297
|
+
}
|
|
235
298
|
async evidencedMutation(method, path, body, commandId) {
|
|
236
299
|
const result = await this.mutateWithEvidence(method, path, body, this.defaultTimeout, commandId);
|
|
237
300
|
if (!result.evidence)
|
|
@@ -305,7 +368,7 @@ export class NodeApiClient {
|
|
|
305
368
|
}
|
|
306
369
|
catch (error) {
|
|
307
370
|
if (admission)
|
|
308
|
-
await
|
|
371
|
+
await settleFailedDispatch(admission, this.resourceContext.actorId, error);
|
|
309
372
|
throw error;
|
|
310
373
|
}
|
|
311
374
|
}
|
|
@@ -487,6 +550,41 @@ export class NodeApiClient {
|
|
|
487
550
|
throw classifyAgentRequestError(error, dispatched);
|
|
488
551
|
}
|
|
489
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
|
+
}
|
|
490
588
|
// ── Ping ──
|
|
491
589
|
async ping() {
|
|
492
590
|
return this.api.get('ping').json();
|
|
@@ -558,10 +656,10 @@ export class NodeApiClient {
|
|
|
558
656
|
return this.api.get('users').json();
|
|
559
657
|
}
|
|
560
658
|
async provisionUser(payload) {
|
|
561
|
-
return this.
|
|
659
|
+
return this.mutateWithCredential('users/provision', payload);
|
|
562
660
|
}
|
|
563
661
|
async changeUserPassword(payload) {
|
|
564
|
-
return this.
|
|
662
|
+
return this.mutateWithCredential('users/change-password', payload);
|
|
565
663
|
}
|
|
566
664
|
}
|
|
567
665
|
//# sourceMappingURL=node-api-client.js.map
|
|
@@ -31,4 +31,26 @@ export function verifyCommandResultSignature(result, activeSecret, previousSecre
|
|
|
31
31
|
}
|
|
32
32
|
return matched;
|
|
33
33
|
}
|
|
34
|
+
export function commandRefusalSigningPayload(refusal) {
|
|
35
|
+
return JSON.stringify({ domain: 'magnus.command-refusal.v1', commandId: refusal.commandId, requestSha256: refusal.requestSha256,
|
|
36
|
+
code: refusal.code, highWater: refusal.highWater, nodeFqdn: refusal.nodeFqdn, leaseId: refusal.leaseId });
|
|
37
|
+
}
|
|
38
|
+
export function commandAbsenceSigningPayload(absence) {
|
|
39
|
+
return JSON.stringify({ domain: 'magnus.command-absence.v1', commandId: absence.commandId, nodeFqdn: absence.nodeFqdn, nonce: absence.nonce,
|
|
40
|
+
ledgerExists: absence.ledgerExists, receipt: absence.receipt, activeLock: absence.activeLock, resourceKey: absence.resourceKey,
|
|
41
|
+
fenceHighWater: absence.fenceHighWater, markerState: absence.markerState, markerNodeId: absence.markerNodeId, admissionFailedFence: absence.admissionFailedFence });
|
|
42
|
+
}
|
|
43
|
+
/** The fence high-water a preflight reports, bound to the node and a fresh nonce. Only this can move a server generation. */
|
|
44
|
+
export function fenceProofSigningPayload(proof) {
|
|
45
|
+
return JSON.stringify({ domain: 'magnus.fence-high-water.v1', nonce: proof.nonce, nodeFqdn: proof.nodeFqdn,
|
|
46
|
+
fenceHighWater: Object.fromEntries(Object.entries(proof.fenceHighWater).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) });
|
|
47
|
+
}
|
|
48
|
+
/** HMAC check of a signed refusal or absence payload against the active or previous result key. */
|
|
49
|
+
export function verifyCommandEvidence(payload, signature, activeSecret, previousSecret = '') {
|
|
50
|
+
if (typeof signature !== 'string' || !/^[a-f0-9]{64}$/.test(signature) || activeSecret.length < 32)
|
|
51
|
+
return false;
|
|
52
|
+
const actual = Buffer.from(signature, 'hex');
|
|
53
|
+
return [activeSecret, previousSecret].filter(secret => secret.length >= 32)
|
|
54
|
+
.some(secret => timingSafeEqual(actual, createHmac('sha256', secret).update(payload).digest()));
|
|
55
|
+
}
|
|
34
56
|
//# sourceMappingURL=command-result-signature.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Versioned native HTTP identity. Hashes bind request bytes; bodies stay off receipts. */
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { commandFenceSchema } from '../schemas/node-responses.js';
|
|
5
5
|
export const managedClaimSchema = z.object({
|
|
@@ -23,12 +23,26 @@ export const managedHttpRequestSchema = z.object({
|
|
|
23
23
|
export const MANAGED_HTTP_COMMAND = '@magnus/native-http/v1';
|
|
24
24
|
export const MANAGED_CLAIM_HEADER = 'x-magnus-resource-claim';
|
|
25
25
|
export function sha256(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
26
|
-
|
|
26
|
+
function assertCanonicalMutation(method, path) {
|
|
27
27
|
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) || !/^\/[a-zA-Z0-9/_%.:-]+$/.test(path)
|
|
28
28
|
|| path.includes('..') || path.includes('//'))
|
|
29
29
|
throw new Error('Canonical native mutation path required');
|
|
30
|
+
}
|
|
31
|
+
export function httpIdentity(commandId, method, path, body, timeoutSeconds) {
|
|
32
|
+
assertCanonicalMutation(method, path);
|
|
30
33
|
return { commandId, command: MANAGED_HTTP_COMMAND, args: [method, path, sha256(body)], timeoutSeconds };
|
|
31
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Identity for a mutation whose body holds a credential. A stored digest of
|
|
37
|
+
* such a body could be guessed offline, so the digest slot holds a random value
|
|
38
|
+
* instead of binding the body. That is safe only because these paths are absent
|
|
39
|
+
* from the native completion registry on every released agent: they are never
|
|
40
|
+
* sent through managed HTTP, so no agent ever recomputes this identity.
|
|
41
|
+
*/
|
|
42
|
+
export function credentialMutationIdentity(commandId, method, path, timeoutSeconds) {
|
|
43
|
+
assertCanonicalMutation(method, path);
|
|
44
|
+
return { commandId, command: MANAGED_HTTP_COMMAND, args: [method, path, sha256(randomUUID())], timeoutSeconds };
|
|
45
|
+
}
|
|
32
46
|
export function httpReceiptBody(method, path, status, body) {
|
|
33
47
|
return JSON.stringify({ version: 1, method, path, status, responseSha256: sha256(body) });
|
|
34
48
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { getDb } from '../db/client.js';
|
|
3
|
+
import { fenceProofSigningPayload, verifyCommandEvidence } from '../security/command-result-signature.js';
|
|
4
|
+
import { NodeApiClient } from '../sdk/node-api-client.js';
|
|
5
|
+
import { getNodeManagerSecret, RESULT_SIGNING_PREVIOUS_SECRET, RESULT_SIGNING_SECRET } from '../utils/env.js';
|
|
6
|
+
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
7
|
+
import { advanceCommandFence, checkReportedFence, fenceIsAhead, isOwnFenceKey } from './command-fence-repair.js';
|
|
8
|
+
import { recoverAbsentLeases } from './command-resource-leases.js';
|
|
9
|
+
/**
|
|
10
|
+
* Why self-upgrade is off, from what the agent says. Only an operator's choice
|
|
11
|
+
* and a layout that cannot do it are expected; anything else is a problem someone
|
|
12
|
+
* has to fix, so it must not read as expected and let a run look finished.
|
|
13
|
+
*/
|
|
14
|
+
export function selfUpgradeOffCode(report) {
|
|
15
|
+
switch (report.reasonCode) {
|
|
16
|
+
case 'SWITCHED_OFF':
|
|
17
|
+
case 'CONTAINER':
|
|
18
|
+
case 'LEGACY_LAYOUT': return 'SELF_UPGRADE_DISABLED';
|
|
19
|
+
case 'NOT_WRITABLE': return 'PREFLIGHT_NOT_WRITABLE';
|
|
20
|
+
case 'NPM': return 'PREFLIGHT_NPM';
|
|
21
|
+
case undefined:
|
|
22
|
+
case null:
|
|
23
|
+
// An agent older than 1.1.8 sends text only. Its layouts and this one exact string are all it can say safely.
|
|
24
|
+
return report.layout !== 'versioned' || report.reason === 'MAGNUS_AGENT_SELF_UPGRADE=false on this node' ? 'SELF_UPGRADE_DISABLED' : 'SELF_UPGRADE_UNAVAILABLE';
|
|
25
|
+
default: return 'SELF_UPGRADE_UNAVAILABLE';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Hold a report against what an upgrade needs. A field the agent left out proves nothing either way. */
|
|
29
|
+
export function evaluatePreflight(report, context) {
|
|
30
|
+
const problems = [];
|
|
31
|
+
const add = (code, detail, blocking = true) => problems.push({ code, detail, blocking });
|
|
32
|
+
const versioned = report.layout === 'versioned';
|
|
33
|
+
if (report.enabled === false && !versioned)
|
|
34
|
+
add(selfUpgradeOffCode({ reasonCode: report.reasonCode, reason: report.reason ?? null, layout: report.layout ?? 'legacy' }), report.reason ?? 'self-upgrade is unavailable on this node');
|
|
35
|
+
if (versioned) {
|
|
36
|
+
if (report.writable && !report.writable.ok)
|
|
37
|
+
add('PREFLIGHT_NOT_WRITABLE', `the agent cannot write ${report.writable.blocked?.join(', ') || 'its install directory'}`);
|
|
38
|
+
if (report.npm && (!report.npm.present || report.npm.problem))
|
|
39
|
+
add('PREFLIGHT_NPM', report.npm.problem ?? 'npm is missing');
|
|
40
|
+
if (report.fromVersionComplete === false)
|
|
41
|
+
add('PREFLIGHT_FROM_INCOMPLETE', `versions/${report.version} is incomplete or ./current does not run it, so a failed upgrade could not switch back`);
|
|
42
|
+
if (report.enabled === false && !report.writable?.blocked?.length && !report.npm?.problem)
|
|
43
|
+
add(selfUpgradeOffCode({ reasonCode: report.reasonCode, reason: report.reason ?? null, layout: 'versioned' }), report.reason ?? 'self-upgrade is unavailable on this node');
|
|
44
|
+
}
|
|
45
|
+
if (report.disk?.low)
|
|
46
|
+
add('PREFLIGHT_DISK_LOW', `${mib(report.disk.freeBytes)} MiB free in the install directory; an upgrade needs ${mib(report.disk.requiredBytes)} MiB`);
|
|
47
|
+
if (report.engines?.compatible === false)
|
|
48
|
+
add('PREFLIGHT_ENGINE', report.engines.problem ?? `the target does not support Node.js ${report.engines.node}`);
|
|
49
|
+
const ledger = report.ledger;
|
|
50
|
+
if (ledger && !ledger.readable)
|
|
51
|
+
add('LEDGER_UNAVAILABLE', ledger.error ?? 'the command ledger cannot be read', context.managed);
|
|
52
|
+
// A command running right now finishes by itself: it is a busy slot, which dispatch refuses and the end of the wave retries.
|
|
53
|
+
else if (ledger?.activeLock?.receipt === 'running')
|
|
54
|
+
add('RESOURCE_BUSY', `${ledger.activeLock.commandId} is running on this node right now`, false);
|
|
55
|
+
else if (ledger?.activeLock && !['none', 'completed', 'reconciled'].includes(ledger.activeLock.receipt)) {
|
|
56
|
+
add('LEDGER_UNRESOLVED', `active.lock is held by ${ledger.activeLock.commandId}, whose receipt is ${ledger.activeLock.receipt}`, context.managed);
|
|
57
|
+
}
|
|
58
|
+
else if (ledger?.unresolvedReceipts)
|
|
59
|
+
add('LEDGER_UNRESOLVED', `${ledger.unresolvedReceipts} receipt(s) have no terminal outcome`, false);
|
|
60
|
+
const marker = report.managedEnrollment;
|
|
61
|
+
if (report.managedEnrollmentState === 'unknown')
|
|
62
|
+
add('ENROLLMENT_MARKER_UNKNOWN', 'the agent could not read its managed-enrollment marker');
|
|
63
|
+
if (marker && (marker.nodeId !== context.nodeId || marker.nodeFqdn !== context.hostname))
|
|
64
|
+
add('ENROLLMENT_MARKER_MISMATCH', `the agent's managed-enrollment marker is for ${marker.nodeFqdn} (${marker.nodeId}), not this node`);
|
|
65
|
+
for (const [key, value] of Object.entries(report.fenceHighWater ?? {})) {
|
|
66
|
+
// Keys of other domains are not this node's to report, and nothing here adopts them.
|
|
67
|
+
if (!isOwnFenceKey(context.nodeId, key))
|
|
68
|
+
continue;
|
|
69
|
+
const check = checkReportedFence(context.nodeId, key, value);
|
|
70
|
+
if (!check.ok)
|
|
71
|
+
add(check.code, `the agent ${check.detail}`);
|
|
72
|
+
else if (fenceIsAhead(check.value, context.generations[key] ?? 0n))
|
|
73
|
+
add('FENCE_AHEAD', `the agent holds fence ${value} for ${key}; the server's generation is ${(context.generations[key] ?? 0n).toString()}`, false);
|
|
74
|
+
}
|
|
75
|
+
return problems;
|
|
76
|
+
}
|
|
77
|
+
const mib = (bytes) => Math.floor(bytes / (1024 * 1024));
|
|
78
|
+
export function preflightStatus(problems) {
|
|
79
|
+
return problems.some(problem => problem.blocking) ? 'blocked' : problems.length ? 'warning' : 'ok';
|
|
80
|
+
}
|
|
81
|
+
/** Bounded concurrency: one slow agent never delays the others by more than its timeout. */
|
|
82
|
+
export async function mapLimit(items, limit, work) {
|
|
83
|
+
const results = new Array(items.length);
|
|
84
|
+
let next = 0;
|
|
85
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
86
|
+
while (next < items.length) {
|
|
87
|
+
const index = next++;
|
|
88
|
+
results[index] = await work(items[index]);
|
|
89
|
+
}
|
|
90
|
+
}));
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
/** The first version that reports its fence, answers the trace and says what it refused. */
|
|
94
|
+
export const CERTAIN_REFUSALS_SINCE = '1.1.8';
|
|
95
|
+
export const PREFLIGHT_CONCURRENCY = 16;
|
|
96
|
+
export const PREFLIGHT_TIMEOUT_MS = 4_000;
|
|
97
|
+
function clientFor(node) {
|
|
98
|
+
return new NodeApiClient({ baseUrl: getNodeBaseUrl(node.ipAddress, node.port), resourceNodeId: node.id, nodeToken: getNodeManagerSecret(),
|
|
99
|
+
resourceActorId: 'system:agent-upgrade', timeout: PREFLIGHT_TIMEOUT_MS });
|
|
100
|
+
}
|
|
101
|
+
const generationsOf = async (nodeId) => {
|
|
102
|
+
const rows = await getDb().commandResourceFence.findMany({ where: { OR: [{ resourceKey: `node:${nodeId}` }, { resourceKey: { startsWith: `node:${nodeId}:` } }] } });
|
|
103
|
+
return Object.fromEntries(rows.map(row => [row.resourceKey, row.generation]));
|
|
104
|
+
};
|
|
105
|
+
/** Ask one agent. Never throws: a node's problem must not stop the check of the others. */
|
|
106
|
+
export async function preflightNode(target, query, options = {}) {
|
|
107
|
+
const checkedAt = new Date(options.now ?? Date.now()).toISOString();
|
|
108
|
+
try {
|
|
109
|
+
const node = await getDb().node.findUnique({ where: { id: target.nodeId }, select: { id: true, ipAddress: true, port: true } });
|
|
110
|
+
if (!node)
|
|
111
|
+
return { status: 'unavailable', checkedAt, problems: [], detail: 'node is no longer in the inventory' };
|
|
112
|
+
const nonce = randomUUID();
|
|
113
|
+
const answer = await clientFor(node).getUpgradePreflight({ ...query, nonce }, options.timeoutMs ?? PREFLIGHT_TIMEOUT_MS);
|
|
114
|
+
if (answer.status !== 'ok')
|
|
115
|
+
return { status: answer.status, checkedAt, problems: [], detail: answer.detail };
|
|
116
|
+
const problems = evaluatePreflight(answer.report, { nodeId: target.nodeId, hostname: target.hostname, managed: target.managed, generations: await generationsOf(target.nodeId) });
|
|
117
|
+
const proof = answer.report.fenceProof;
|
|
118
|
+
const fencesVerified = Boolean(proof && proof.nonce === nonce && proof.nodeFqdn.toLowerCase() === target.hostname.toLowerCase() && verifyCommandEvidence(fenceProofSigningPayload({ nonce, nodeFqdn: proof.nodeFqdn, fenceHighWater: answer.report.fenceHighWater ?? {} }), proof.signature, RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET));
|
|
119
|
+
return { status: preflightStatus(problems), checkedAt, problems, report: answer.report, fencesVerified };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
123
|
+
console.warn(`[agent-upgrade] preflight of ${target.nodeId} failed: ${detail}`);
|
|
124
|
+
return { status: 'unreachable', checkedAt, problems: [], detail };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Ask one node about its leases stuck `unknown`, and settle what its signed account proves. Safe to repeat. */
|
|
128
|
+
export async function recoverNodeLeases(nodeId, actorId, now = Date.now()) {
|
|
129
|
+
const node = await getDb().node.findUnique({ where: { id: nodeId }, select: { id: true, ipAddress: true, port: true, hostname: true } });
|
|
130
|
+
if (!node)
|
|
131
|
+
return [];
|
|
132
|
+
const client = clientFor(node);
|
|
133
|
+
return recoverAbsentLeases(node.id, node.hostname, (commandId, nonce, resourceKey) => client.getCommandTrace(commandId, nonce, resourceKey, PREFLIGHT_TIMEOUT_MS), actorId, now, (commandId, expected) => client.getCommandReceipt(commandId, expected));
|
|
134
|
+
}
|
|
135
|
+
/** What a check repaired, in words. Fence and lease repairs are safe to repeat; each change is audited. */
|
|
136
|
+
export async function healNode(target, preflight, actorId = 'system:agent-upgrade') {
|
|
137
|
+
const healed = [];
|
|
138
|
+
const problems = [...preflight.problems];
|
|
139
|
+
const fences = Object.entries(preflight.report?.fenceHighWater ?? {});
|
|
140
|
+
if (fences.length && !preflight.fencesVerified)
|
|
141
|
+
console.warn(`[agent-upgrade] the fences reported by ${target.nodeId} carry no valid proof; generations unchanged`);
|
|
142
|
+
for (const [resourceKey, highWater] of preflight.fencesVerified ? fences : []) {
|
|
143
|
+
if (!isOwnFenceKey(target.nodeId, resourceKey))
|
|
144
|
+
continue;
|
|
145
|
+
const advanced = await advanceCommandFence({ nodeId: target.nodeId, resourceKey, highWater, source: 'preflight', actorId });
|
|
146
|
+
if (advanced.status !== 'advanced')
|
|
147
|
+
continue;
|
|
148
|
+
healed.push(`moved the fence of ${resourceKey} from ${advanced.from} to ${advanced.to}`);
|
|
149
|
+
const index = problems.findIndex(problem => problem.code === 'FENCE_AHEAD' && problem.detail.includes(` for ${resourceKey};`));
|
|
150
|
+
if (index >= 0)
|
|
151
|
+
problems.splice(index, 1);
|
|
152
|
+
}
|
|
153
|
+
const leases = await recoverNodeLeases(target.nodeId, actorId);
|
|
154
|
+
for (const lease of leases) {
|
|
155
|
+
if (lease.outcome === 'settled')
|
|
156
|
+
healed.push(`settled lease ${lease.commandId} as not dispatched: the agent holds no receipt for it`);
|
|
157
|
+
if (lease.outcome === 'unverified' && lease.code)
|
|
158
|
+
problems.push({ code: lease.code, detail: `lease ${lease.commandId}: ${lease.detail ?? 'unverified'}`, blocking: false });
|
|
159
|
+
}
|
|
160
|
+
return { healed, leases, problems };
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=agent-upgrade-preflight.js.map
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a node could take an agent upgrade, all the time and not just when
|
|
3
|
+
* one starts. Pure evaluation of what the agent's heartbeat reports against the
|
|
4
|
+
* server's own state, so an operator sees drift weeks before an upgrade, and a
|
|
5
|
+
* monitor that notifies when a node has been not ready for a day.
|
|
6
|
+
*/
|
|
7
|
+
import { compareVersions, isVersion, upgradeRemedy } from '@hivemagnus/magnus-contracts';
|
|
8
|
+
import { getDb } from '../db/index.js';
|
|
9
|
+
import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
|
|
10
|
+
import { upgradeSettings } from '../config/features.js';
|
|
11
|
+
import { CERTAIN_REFUSALS_SINCE, recoverNodeLeases, selfUpgradeOffCode } from './agent-upgrade-preflight.js';
|
|
12
|
+
import { storedSelfUpgrade } from './agent-upgrade-service.js';
|
|
13
|
+
import { checkReportedFence, fenceIsAhead, isOwnFenceKey } from './command-fence-repair.js';
|
|
14
|
+
import { ABSENT_RECEIPT_MARGIN_MS, HELD_RESOURCE_STATES } from './command-resource-leases.js';
|
|
15
|
+
import { enrollmentIsComplete } from './resource-enrollment.js';
|
|
16
|
+
import { createNotificationInTransaction } from './notification-service.js';
|
|
17
|
+
import { matchingMaintenance } from './maintenance-service.js';
|
|
18
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
19
|
+
const problem = (code, detail) => {
|
|
20
|
+
const { cause, remedy } = upgradeRemedy(code);
|
|
21
|
+
return { code, detail, cause, remedy };
|
|
22
|
+
};
|
|
23
|
+
export function computeUpgradeReadiness(input) {
|
|
24
|
+
const now = input.now ?? Date.now();
|
|
25
|
+
const report = storedSelfUpgrade(input.metadata);
|
|
26
|
+
const problems = [];
|
|
27
|
+
const enrollment = input.enrollment ?? null;
|
|
28
|
+
if (enrollment?.state === 'draining') {
|
|
29
|
+
const since = enrollment.startedAt ?? enrollment.updatedAt;
|
|
30
|
+
problems.push(problem('ENROLLMENT_DRAINING', `resource enrollment has been draining for ${Math.floor((now - since.getTime()) / DAY_MS)} day(s), since ${since.toISOString().slice(0, 10)}`));
|
|
31
|
+
}
|
|
32
|
+
if (report?.managedEnrollmentState === 'unknown')
|
|
33
|
+
problems.push(problem('ENROLLMENT_MARKER_UNKNOWN', 'the agent could not read its managed-enrollment marker'));
|
|
34
|
+
const marker = report?.managedEnrollment;
|
|
35
|
+
if (marker && (!enrollmentIsComplete(enrollment, marker.nodeFqdn) || marker.nodeId !== input.id || marker.nodeFqdn !== input.hostname))
|
|
36
|
+
problems.push(problem('ENROLLMENT_MARKER_MISMATCH', `the agent holds a managed-enrollment marker for ${marker.nodeFqdn}, but the server has ${enrollment ? `a ${enrollment.state} enrollment for ${enrollment.nodeFqdn}` : 'no enrollment row'}`));
|
|
37
|
+
for (const [key, value] of Object.entries(report?.fenceHighWater ?? {})) {
|
|
38
|
+
if (!isOwnFenceKey(input.id, key))
|
|
39
|
+
continue;
|
|
40
|
+
const check = checkReportedFence(input.id, key, value);
|
|
41
|
+
if (!check.ok)
|
|
42
|
+
problems.push(problem(check.code, `the agent ${check.detail}`));
|
|
43
|
+
else if (fenceIsAhead(check.value, input.generations?.[key] ?? 0n))
|
|
44
|
+
problems.push(problem('FENCE_AHEAD', `the agent holds fence ${value} for ${key}; the server's generation is ${(input.generations?.[key] ?? 0n).toString()}`));
|
|
45
|
+
}
|
|
46
|
+
const ledger = report?.ledger;
|
|
47
|
+
if (ledger && !ledger.readable)
|
|
48
|
+
problems.push(problem('LEDGER_UNAVAILABLE', ledger.error ?? 'the agent cannot read its command ledger'));
|
|
49
|
+
else if (ledger?.unresolvedReceipts)
|
|
50
|
+
problems.push(problem('LEDGER_UNRESOLVED', `${ledger.unresolvedReceipts} receipt(s) in the agent ledger have no terminal outcome${ledger.activeLock ? `; active.lock is held by ${ledger.activeLock.commandId}` : ''}`));
|
|
51
|
+
if (input.heldLeases)
|
|
52
|
+
problems.push(problem('LEASE_HELD', `${input.heldLeases} command lease(s) are reserved, running or unknown`));
|
|
53
|
+
// Only a node that could upgrade itself needs npm and disk for it. One that is off because of a problem says which.
|
|
54
|
+
const canSelfUpgrade = report?.layout === 'versioned' && report.enabled;
|
|
55
|
+
if (report && !report.enabled && report.layout === 'versioned') {
|
|
56
|
+
const code = selfUpgradeOffCode(report);
|
|
57
|
+
if (!upgradeRemedy(code).informational)
|
|
58
|
+
problems.push(problem(code, report.reason ?? 'self-upgrade is unavailable on this node'));
|
|
59
|
+
}
|
|
60
|
+
if (canSelfUpgrade && report?.disk?.low)
|
|
61
|
+
problems.push(problem('PREFLIGHT_DISK_LOW', `${Math.floor(report.disk.freeBytes / (1024 * 1024))} MiB free in the install directory; an upgrade needs ${Math.floor(report.disk.requiredBytes / (1024 * 1024))} MiB`));
|
|
62
|
+
if (canSelfUpgrade && report?.npm && (!report.npm.present || report.npm.problem))
|
|
63
|
+
problems.push(problem('PREFLIGHT_NPM', report.npm.problem ?? 'npm is missing on this node'));
|
|
64
|
+
const reportsDrift = Boolean(report && (report.ledger || report.disk || report.npm));
|
|
65
|
+
return { status: problems.length ? 'not-ready' : reportsDrift && report?.enabled ? 'ok' : 'unknown', problems, observedAt: report?.observedAt ?? null };
|
|
66
|
+
}
|
|
67
|
+
/** Readiness of many nodes at once, for list pages: one query per kind of server state. Never fails a page. */
|
|
68
|
+
export async function readinessOfNodes(nodes, now = Date.now()) {
|
|
69
|
+
if (!nodes.length)
|
|
70
|
+
return new Map();
|
|
71
|
+
try {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
const ids = nodes.map(node => node.id);
|
|
74
|
+
const [enrollments, fences, leases] = await Promise.all([
|
|
75
|
+
db.resourceEnrollment.findMany({ where: { nodeId: { in: ids } }, select: { nodeId: true, state: true, nodeFqdn: true, startedAt: true, updatedAt: true } }),
|
|
76
|
+
db.commandResourceFence.findMany({ where: { OR: ids.flatMap(id => [{ resourceKey: `node:${id}` }, { resourceKey: `node:${id}:ardent` }]) } }),
|
|
77
|
+
db.commandResourceLease.groupBy({ by: ['nodeId'], where: { nodeId: { in: ids }, state: { in: [...HELD_RESOURCE_STATES] } }, _count: { _all: true } }),
|
|
78
|
+
]);
|
|
79
|
+
const generations = Object.fromEntries(fences.map(row => [row.resourceKey, row.generation]));
|
|
80
|
+
return new Map(nodes.map(node => [node.id, computeUpgradeReadiness({ id: node.id, hostname: node.hostname, metadata: node.metadata, now,
|
|
81
|
+
enrollment: enrollments.find(row => row.nodeId === node.id) ?? null, generations,
|
|
82
|
+
heldLeases: leases.find(row => row.nodeId === node.id)?._count._all ?? 0 })]));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
console.warn(`[upgrade-readiness] could not be computed: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
+
return new Map();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ── Monitor: notify when a node has been not ready for a day ──
|
|
90
|
+
/** A node not ready for this long gets a notification, once per episode. */
|
|
91
|
+
export const NOT_READY_NOTIFY_MS = DAY_MS;
|
|
92
|
+
const CHECK_INTERVAL_MS = 10 * 60_000;
|
|
93
|
+
let timer = null;
|
|
94
|
+
/** How often one node is asked about its stuck leases. */
|
|
95
|
+
export const LEASE_RECOVERY_INTERVAL_MS = 60 * 60_000;
|
|
96
|
+
const lastLeaseRecovery = new Map();
|
|
97
|
+
export function resetLeaseRecoveryLimits() { lastLeaseRecovery.clear(); }
|
|
98
|
+
/**
|
|
99
|
+
* Settle leases nobody will come back for: no run may ever start for a node that
|
|
100
|
+
* already runs the target. Past the deadline plus a margin, on agents that answer
|
|
101
|
+
* the trace, at most once an hour per node; only signed ledger evidence settles
|
|
102
|
+
* one, and each settlement is audited by recoverAbsentLeases.
|
|
103
|
+
*/
|
|
104
|
+
async function recoverStuckLeases(now) {
|
|
105
|
+
const db = getDb();
|
|
106
|
+
const stuck = await db.commandResourceLease.findMany({ where: { state: 'unknown', deadline: { lt: new Date(now - ABSENT_RECEIPT_MARGIN_MS) } }, select: { nodeId: true }, distinct: ['nodeId'] });
|
|
107
|
+
if (!stuck.length)
|
|
108
|
+
return;
|
|
109
|
+
const nodes = await db.node.findMany({ where: { id: { in: stuck.map(row => row.nodeId) }, status: { in: ['ONLINE', 'DEGRADED'] } }, select: { id: true, name: true, agent: { select: { version: true } } } });
|
|
110
|
+
for (const node of nodes) {
|
|
111
|
+
const version = node.agent?.version ?? '';
|
|
112
|
+
if (!isVersion(version) || compareVersions(version, CERTAIN_REFUSALS_SINCE) < 0)
|
|
113
|
+
continue;
|
|
114
|
+
if (now - (lastLeaseRecovery.get(node.id) ?? -Infinity) < LEASE_RECOVERY_INTERVAL_MS)
|
|
115
|
+
continue;
|
|
116
|
+
lastLeaseRecovery.set(node.id, now);
|
|
117
|
+
const results = await recoverNodeLeases(node.id, 'system:upgrade-readiness', now).catch((error) => {
|
|
118
|
+
console.warn(`[upgrade-readiness] lease recovery on ${node.name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
119
|
+
return [];
|
|
120
|
+
});
|
|
121
|
+
for (const lease of results)
|
|
122
|
+
if (lease.outcome === 'unverified')
|
|
123
|
+
console.warn(`[upgrade-readiness] lease ${lease.commandId} on ${node.name} stays held: ${lease.code}: ${lease.detail}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export async function checkUpgradeReadiness(now = Date.now()) {
|
|
127
|
+
const db = getDb();
|
|
128
|
+
await recoverStuckLeases(now).catch((error) => console.warn(`[upgrade-readiness] lease recovery failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
129
|
+
const nodes = await db.node.findMany({ where: { id: { notIn: SENTINEL_NODE_IDS }, status: { in: ['ONLINE', 'DEGRADED'] } }, select: { id: true, name: true, hostname: true, metadata: true } });
|
|
130
|
+
const readiness = await readinessOfNodes(nodes, now);
|
|
131
|
+
// Lease recovery above runs regardless: leases settle whether or not this server drives upgrades.
|
|
132
|
+
const notify = upgradeSettings().agentUpgrades;
|
|
133
|
+
for (const node of nodes) {
|
|
134
|
+
const state = readiness.get(node.id);
|
|
135
|
+
if (!state)
|
|
136
|
+
continue;
|
|
137
|
+
// One node's failure is that node's: the rest of the tick still runs.
|
|
138
|
+
await checkOneNode(node, state, notify, now).catch((error) => console.warn(`[upgrade-readiness] ${node.name}: ${error instanceof Error ? error.message : String(error)}`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function checkOneNode(node, state, notify, now) {
|
|
142
|
+
const db = getDb();
|
|
143
|
+
const metadata = parseMetadata(node.metadata);
|
|
144
|
+
const since = typeof metadata['upgradeNotReadySince'] === 'string' ? metadata['upgradeNotReadySince'] : null;
|
|
145
|
+
// Read again right before writing: heartbeats keep their report in the same column.
|
|
146
|
+
const patch = async (change) => {
|
|
147
|
+
const row = await db.node.findUnique({ where: { id: node.id }, select: { metadata: true } });
|
|
148
|
+
await db.node.update({ where: { id: node.id }, data: { metadata: JSON.stringify(change(parseMetadata(row?.metadata))) } });
|
|
149
|
+
};
|
|
150
|
+
if (state.status !== 'not-ready') {
|
|
151
|
+
if (since)
|
|
152
|
+
await patch(withoutEpisode);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!since)
|
|
156
|
+
return patch(current => ({ ...current, upgradeNotReadySince: new Date(now).toISOString() }));
|
|
157
|
+
if (!notify || metadata['upgradeNotReadyNotifiedAt'] || now - Date.parse(since) < NOT_READY_NOTIFY_MS)
|
|
158
|
+
return;
|
|
159
|
+
const maintenance = await matchingMaintenance(node.id);
|
|
160
|
+
await db.$transaction(async (tx) => {
|
|
161
|
+
// Several servers may run this monitor: the lock and a fresh read make one of them the one that notifies.
|
|
162
|
+
await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`upgrade-not-ready:${node.id}`}, 0))`;
|
|
163
|
+
const row = await tx.node.findUnique({ where: { id: node.id }, select: { metadata: true } });
|
|
164
|
+
const current = parseMetadata(row?.metadata);
|
|
165
|
+
if (current['upgradeNotReadyNotifiedAt'])
|
|
166
|
+
return;
|
|
167
|
+
await createNotificationInTransaction(tx, {
|
|
168
|
+
type: 'node.upgrade_not_ready', severity: 'warning', title: `${node.name} could not take an agent upgrade`,
|
|
169
|
+
message: `Not ready since ${since}: ${state.problems.map(item => `${item.code}: ${item.detail}. ${item.remedy}`).join(' ')}`.slice(0, 2000),
|
|
170
|
+
nodeId: node.id, nodeName: node.name, metadata: { since, codes: state.problems.map(item => item.code) },
|
|
171
|
+
}, maintenance?.id ?? null);
|
|
172
|
+
await tx.node.update({ where: { id: node.id }, data: { metadata: JSON.stringify({ ...current, upgradeNotReadyNotifiedAt: new Date(now).toISOString() }) } });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function parseMetadata(raw) {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(raw ?? '{}');
|
|
178
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
console.warn(`[upgrade-readiness] node metadata is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
182
|
+
return {};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function withoutEpisode(metadata) {
|
|
186
|
+
const { upgradeNotReadySince: _since, upgradeNotReadyNotifiedAt: _notified, ...rest } = metadata;
|
|
187
|
+
return rest;
|
|
188
|
+
}
|
|
189
|
+
export function startUpgradeReadinessMonitor() {
|
|
190
|
+
if (timer)
|
|
191
|
+
return;
|
|
192
|
+
const run = () => void checkUpgradeReadiness().catch((error) => console.error(`[upgrade-readiness] check failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
193
|
+
run();
|
|
194
|
+
timer = setInterval(run, CHECK_INTERVAL_MS);
|
|
195
|
+
timer.unref?.();
|
|
196
|
+
}
|
|
197
|
+
export function stopUpgradeReadinessMonitor() {
|
|
198
|
+
if (timer) {
|
|
199
|
+
clearInterval(timer);
|
|
200
|
+
timer = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=agent-upgrade-readiness.js.map
|