@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repairing the distance between the server's command fence generation and a
|
|
3
|
+
* node ledger that has seen a higher fence (written by an older build, or
|
|
4
|
+
* surviving a database restore). A fence is an ordering token the server
|
|
5
|
+
* owns: the ledger refuses anything at or below its high-water, so the only
|
|
6
|
+
* safe repair is to move the server's generation UP.
|
|
7
|
+
*
|
|
8
|
+
* Trust, precisely. The value must be signed with the fleet's result key, under
|
|
9
|
+
* a nonce this server just chose (a preflight) or bound to this node's lease and
|
|
10
|
+
* request (a STALE_FENCE refusal), and it must arrive over a connection this server
|
|
11
|
+
* opened to the node's inventory address. Only that node's own keys are accepted, and
|
|
12
|
+
* the cap keeps a value from pushing the column near BIGINT overflow. The result
|
|
13
|
+
* key and the node credential are shared by the fleet, so these signatures tell
|
|
14
|
+
* agents apart only by what they claim: whoever holds them can sign for any node.
|
|
15
|
+
* The limit is the connection, which ends at whatever the inventory says. A
|
|
16
|
+
* heartbeat with the fleet credential can repoint a node only while its stored
|
|
17
|
+
* address is loopback or unroutable (shouldRepairNodeIp), or to the DNS name of its own
|
|
18
|
+
* agent certificate; a routable address stays. So a holder of the fleet secrets,
|
|
19
|
+
* with a certificate the server trusts for its own address, could answer for a node
|
|
20
|
+
* in that state. It can then inflate that node's counters and settle its leases that
|
|
21
|
+
* left no receipt there. The cost of the counter is only numbering: a generation
|
|
22
|
+
* moves up and nothing depends on its size.
|
|
23
|
+
* Heartbeat values are never used for this.
|
|
24
|
+
*
|
|
25
|
+
* command_resource_fences has no database trigger. The increase-only triggers
|
|
26
|
+
* guard the separate shared_resource_fences table, so "never lower" is enforced
|
|
27
|
+
* here, under the same advisory lock admission takes for this node.
|
|
28
|
+
*/
|
|
29
|
+
import { getDb } from '../db/index.js';
|
|
30
|
+
import { logAuditEvent } from './audit-service.js';
|
|
31
|
+
/** Well below BIGINT's 9223372036854775807, with room for every later increment. */
|
|
32
|
+
export const FENCE_HIGH_WATER_CAP = 4000000000000000000n;
|
|
33
|
+
/** A node's fences live under `node:<id>` or `node:<id>:<scope>`. */
|
|
34
|
+
export const isOwnFenceKey = (nodeId, resourceKey) => resourceKey === `node:${nodeId}` || resourceKey.startsWith(`node:${nodeId}:`);
|
|
35
|
+
export function checkReportedFence(nodeId, resourceKey, value) {
|
|
36
|
+
if (!isOwnFenceKey(nodeId, resourceKey))
|
|
37
|
+
return { ok: false, code: 'FENCE_INVALID', detail: `reported a fence for ${resourceKey.slice(0, 100)}, which is not its own` };
|
|
38
|
+
if (typeof value !== 'string' || !/^[1-9][0-9]{0,18}$/.test(value))
|
|
39
|
+
return { ok: false, code: 'FENCE_INVALID', detail: `reported a fence for ${resourceKey} that is not a positive decimal` };
|
|
40
|
+
const parsed = BigInt(value);
|
|
41
|
+
if (parsed > FENCE_HIGH_WATER_CAP)
|
|
42
|
+
return { ok: false, code: 'FENCE_OVERFLOW', detail: `reports fence ${value} for ${resourceKey}, above the ${FENCE_HIGH_WATER_CAP} the server adopts` };
|
|
43
|
+
return { ok: true, value: parsed };
|
|
44
|
+
}
|
|
45
|
+
/** The generation admission would hand out next is below or at the agent's high-water. */
|
|
46
|
+
export const fenceIsAhead = (highWater, generation) => highWater >= generation + 1n;
|
|
47
|
+
/** Move the generation to high-water + 1, audited. Never lowers: the check and the write hold the node's admission lock. */
|
|
48
|
+
export async function advanceCommandFence(input) {
|
|
49
|
+
const check = checkReportedFence(input.nodeId, input.resourceKey, input.highWater);
|
|
50
|
+
if (!check.ok) {
|
|
51
|
+
console.warn(`[command-fence] node ${input.nodeId} ${check.detail}; generation unchanged`);
|
|
52
|
+
return { status: 'rejected', code: check.code, detail: check.detail };
|
|
53
|
+
}
|
|
54
|
+
const moved = await getDb().$transaction(async (tx) => {
|
|
55
|
+
await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`command-resource-node:${input.nodeId}`}, 0))`;
|
|
56
|
+
const row = await tx.commandResourceFence.findUnique({ where: { resourceKey: input.resourceKey } });
|
|
57
|
+
const from = row?.generation ?? 0n;
|
|
58
|
+
if (!fenceIsAhead(check.value, from))
|
|
59
|
+
return null;
|
|
60
|
+
const to = check.value + 1n;
|
|
61
|
+
await tx.commandResourceFence.upsert({ where: { resourceKey: input.resourceKey },
|
|
62
|
+
create: { resourceKey: input.resourceKey, generation: to }, update: { generation: to } });
|
|
63
|
+
return { from, to };
|
|
64
|
+
});
|
|
65
|
+
if (!moved)
|
|
66
|
+
return { status: 'current' };
|
|
67
|
+
await logAuditEvent({ action: 'agent.fence.advanced', actorId: input.actorId ?? 'system:agent-upgrade', actorType: 'system', targetType: 'node', targetId: input.nodeId,
|
|
68
|
+
details: { resourceKey: input.resourceKey, oldGeneration: moved.from.toString(), newGeneration: moved.to.toString(), reportedHighWater: check.value.toString(), source: input.source } })
|
|
69
|
+
.catch((error) => console.warn(`[command-fence] audit of ${input.nodeId} failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
70
|
+
return { status: 'advanced', ...moved };
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=command-fence-repair.js.map
|
|
@@ -6,18 +6,19 @@ import { assertNodeAdmissionOpen } from './admission-fence.js';
|
|
|
6
6
|
* A node requires the explicit durable participant drain before its first lease; switching the flag off does
|
|
7
7
|
* not remove held/unknown ownership or permit a legacy bypass on that node.
|
|
8
8
|
*/
|
|
9
|
-
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
10
10
|
import { nanoid } from 'nanoid';
|
|
11
11
|
import { getDb } from '../db/index.js';
|
|
12
12
|
import { HttpError } from '../middleware/error-handler.js';
|
|
13
13
|
import { commandRequestDigest } from '../security/command-request.js';
|
|
14
14
|
import { commandFenceSchema } from '../schemas/node-responses.js';
|
|
15
|
-
import { verifyCommandResultSignature } from '../security/command-result-signature.js';
|
|
15
|
+
import { commandAbsenceSigningPayload, commandRefusalSigningPayload, verifyCommandEvidence, verifyCommandResultSignature } from '../security/command-result-signature.js';
|
|
16
16
|
import { RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET } from '../utils/env.js';
|
|
17
17
|
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
18
18
|
import { COMMAND_COMPLETION_REGISTRY, COMMAND_COMPLETION_REGISTRY_SHA256, commandCompletionDescriptor } from '../security/command-completion-registry.js';
|
|
19
19
|
import { MANAGED_HTTP_COMMAND } from '../security/managed-http.js';
|
|
20
20
|
import { NATIVE_COMPLETION_REGISTRY, NATIVE_COMPLETION_REGISTRY_SHA256, nativeCompletionDescriptor } from '../security/native-completion-registry.js';
|
|
21
|
+
import { logAuditEvent } from './audit-service.js';
|
|
21
22
|
const HELD = ['reserved', 'running', 'unknown'];
|
|
22
23
|
export class ResourceLeaseConflict extends HttpError {
|
|
23
24
|
constructor() { super(409, 'RESOURCE_LEASE_CONFLICT', 'Node or service has an unsettled command owner; reconcile its evidence before retrying'); }
|
|
@@ -204,28 +205,154 @@ export async function markResourceDispatched(admission, verifyBeforeDispatch) {
|
|
|
204
205
|
throw new HttpError(409, 'RESOURCE_DEADLINE_INVALID', 'Resource admission expired or changed before dispatch');
|
|
205
206
|
});
|
|
206
207
|
}
|
|
207
|
-
|
|
208
|
+
/** The agent's signed word that this exact request did not start and has no receipt. Unsigned or mismatched words prove nothing. */
|
|
209
|
+
function refusalBindsLease(row, nodeFqdn, refusal) {
|
|
210
|
+
return refusal.commandId === row.commandId && refusal.requestSha256 === row.requestSha256 && refusal.leaseId === row.id && refusal.nodeFqdn === nodeFqdn
|
|
211
|
+
&& verifyCommandEvidence(commandRefusalSigningPayload(refusal), refusal.signature, RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Close a lease whose dispatch ended without a result. A lease never sent, or
|
|
215
|
+
* one the agent proved it refused, is `not-dispatched`; anything else is
|
|
216
|
+
* `unknown` and held until reconciled. Answers whether the refusal settled it.
|
|
217
|
+
*/
|
|
218
|
+
export async function failResourceCommand(admission, refusal) {
|
|
208
219
|
const db = getDb();
|
|
209
|
-
|
|
220
|
+
return db.$transaction(async (tx) => {
|
|
210
221
|
const row = await tx.commandResourceLease.findUnique({ where: { id: admission.leaseId } });
|
|
211
222
|
if (!row || row.state === 'released')
|
|
212
|
-
return;
|
|
223
|
+
return false;
|
|
213
224
|
const notSent = row.state === 'reserved';
|
|
225
|
+
const refused = !notSent && refusal !== undefined && refusalBindsLease(row, admission.nodeFqdn, refusal);
|
|
226
|
+
if (refusal && !notSent && !refused)
|
|
227
|
+
console.warn(`[resource-leases] refusal for ${row.commandId} does not verify; the lease stays uncertain`);
|
|
214
228
|
const changed = await tx.commandResourceLease.updateMany({ where: { id: row.id, state: row.state }, data: notSent
|
|
215
229
|
? { state: 'released', settledAt: new Date(), settlement: 'not-dispatched',
|
|
216
230
|
evidenceSha256: createHash('sha256').update(`pre-post:${row.commandId}:${row.requestSha256}`).digest('hex') }
|
|
217
|
-
: { state: '
|
|
231
|
+
: refused ? { state: 'released', settledAt: new Date(), settlement: 'not-dispatched',
|
|
232
|
+
evidenceSha256: createHash('sha256').update(`certain-refusal:${JSON.stringify(refusal)}`).digest('hex') }
|
|
233
|
+
: { state: 'unknown' } });
|
|
218
234
|
if (!changed.count)
|
|
219
|
-
return;
|
|
235
|
+
return false;
|
|
220
236
|
if (row.directCommand) {
|
|
221
237
|
await tx.remoteCommand.updateMany({ where: { id: row.commandId, status: 'RUNNING' }, data: { status: 'FAILED', completedAt: new Date() } });
|
|
222
238
|
await tx.commandResult.upsert({ where: { commandId: row.commandId }, update: {}, create: {
|
|
223
239
|
commandId: row.commandId, exitCode: -1, stdout: '', durationMs: 0,
|
|
224
|
-
stderr: notSent ? 'Resource admission ended before dispatch.' :
|
|
240
|
+
stderr: notSent ? 'Resource admission ended before dispatch.' : refused ? `The node refused the request before it started (${refusal.code}); nothing ran.`
|
|
241
|
+
: 'Execution outcome unknown; resource ownership retained until verified evidence reconciliation.'
|
|
225
242
|
} });
|
|
226
243
|
}
|
|
244
|
+
return refused;
|
|
227
245
|
});
|
|
228
246
|
}
|
|
247
|
+
/** Past the deadline the ledger refuses a late request, and /claim stops confirming it; the margin covers clock skew. */
|
|
248
|
+
export const ABSENT_RECEIPT_MARGIN_MS = 60_000;
|
|
249
|
+
/**
|
|
250
|
+
* What a signed trace with no receipt says about a lease held for this node, or
|
|
251
|
+
* why it says nothing. "No receipt" alone proves nothing, since a ledger that is
|
|
252
|
+
* gone holds nothing either. execute() writes in this order: claim, fence, marker,
|
|
253
|
+
* durable receipt, and only then runs the callback. So in a ledger that is there:
|
|
254
|
+
* - no fence, with this node's marker: the marker shows this is its ledger, and the
|
|
255
|
+
* fence comes before everything that could run, so no request passed admission.
|
|
256
|
+
* - no fence and no marker: nothing shows this is the ledger the command would have
|
|
257
|
+
* used. Stays held.
|
|
258
|
+
* - high-water below the lease's fence: the request never passed admission.
|
|
259
|
+
* - above it: the stale-refusal case, or a later fence won.
|
|
260
|
+
* - equal, with this command's claim: a crash or a failed write between the claim and
|
|
261
|
+
* the receipt, which never started.
|
|
262
|
+
* - equal, with its durable record of a failed admission for this fence: the same.
|
|
263
|
+
* - equal with neither: the receipt was removed, or a build without such records
|
|
264
|
+
* failed the admission. These look the same, so it stays held.
|
|
265
|
+
* Not provable from here: a ledger wiped and used again looks like a later fence
|
|
266
|
+
* winning, and one wiped and told its enrollment again looks like the first case.
|
|
267
|
+
*/
|
|
268
|
+
export function judgeTrace(answer, nodeId, leaseFence) {
|
|
269
|
+
const no = (code, detail) => ({ settle: false, code, detail });
|
|
270
|
+
if (!answer.ledgerExists)
|
|
271
|
+
return no('LEDGER_MISSING', 'the agent reports no ledger directory');
|
|
272
|
+
if (answer.markerState === 'unknown')
|
|
273
|
+
return no('ENROLLMENT_MARKER_UNKNOWN', 'the agent could not read its enrollment marker');
|
|
274
|
+
if (answer.markerState === 'present' && answer.markerNodeId !== nodeId)
|
|
275
|
+
return no('ENROLLMENT_MARKER_MISMATCH', `the agent's enrollment marker names ${answer.markerNodeId ?? 'nothing'}`);
|
|
276
|
+
if (answer.fenceHighWater === null || !/^[0-9]{1,20}$/.test(answer.fenceHighWater)) {
|
|
277
|
+
if (answer.markerState === 'present' && !answer.activeLock)
|
|
278
|
+
return { settle: true };
|
|
279
|
+
return no('LEDGER_NO_FENCE', `the ledger holds no fence for ${answer.resourceKey} and nothing shows it is this node's`);
|
|
280
|
+
}
|
|
281
|
+
const highWater = BigInt(answer.fenceHighWater);
|
|
282
|
+
if (highWater === leaseFence && !answer.activeLock && answer.admissionFailedFence !== String(leaseFence))
|
|
283
|
+
return no('LEDGER_EVIDENCE_INCONSISTENT', `the ledger accepted fence ${leaseFence} but holds no receipt, claim or failed admission for it`);
|
|
284
|
+
return { settle: true };
|
|
285
|
+
}
|
|
286
|
+
export async function recoverAbsentLeases(nodeId, nodeFqdn, trace, actorId = 'system:agent-upgrade', now = Date.now(), signedReceipt) {
|
|
287
|
+
const db = getDb();
|
|
288
|
+
const rows = await db.commandResourceLease.findMany({ where: { nodeId, state: 'unknown' } });
|
|
289
|
+
const results = [];
|
|
290
|
+
for (const row of rows) {
|
|
291
|
+
const { commandId } = row;
|
|
292
|
+
if (row.completionClass === 'managed-scheduler' || row.completionDescriptorId === 'skill-install') {
|
|
293
|
+
results.push({ commandId, outcome: 'unsupported' });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (row.deadline.getTime() + ABSENT_RECEIPT_MARGIN_MS > now) {
|
|
297
|
+
results.push({ commandId, outcome: 'not-past-deadline' });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const coordination = commandFenceSchema.parse(JSON.parse(row.coordinationJson));
|
|
301
|
+
const nonce = randomUUID();
|
|
302
|
+
let answer;
|
|
303
|
+
try {
|
|
304
|
+
answer = await trace(commandId, nonce, coordination.resourceKey);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
console.warn(`[resource-leases] trace of ${commandId} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
308
|
+
results.push({ commandId, outcome: 'unavailable' });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
// An agent without this route keeps the current behaviour: held until reconciled.
|
|
312
|
+
if (!answer) {
|
|
313
|
+
results.push({ commandId, outcome: 'unsupported' });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (answer.commandId !== commandId || answer.nonce !== nonce || answer.nodeFqdn !== nodeFqdn || answer.resourceKey !== coordination.resourceKey
|
|
317
|
+
|| !verifyCommandEvidence(commandAbsenceSigningPayload(answer), answer.signature, RESULT_SIGNING_SECRET, RESULT_SIGNING_PREVIOUS_SECRET)) {
|
|
318
|
+
console.warn(`[resource-leases] trace of ${commandId} does not verify; the lease stays held`);
|
|
319
|
+
results.push({ commandId, outcome: 'unverified', code: 'LEDGER_EVIDENCE_INCONSISTENT', detail: 'the signed answer does not bind this command, node and nonce' });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (answer.receipt) {
|
|
323
|
+
// The usual path out for a receipt: a signed result or reconciliation settles it, anything else stays held.
|
|
324
|
+
await signedReceipt?.(commandId, { nodeFqdn, requestSha256: row.requestSha256 }).catch((error) => {
|
|
325
|
+
console.warn(`[resource-leases] receipt of ${commandId} settles nothing: ${error instanceof Error ? error.message : String(error)}`);
|
|
326
|
+
});
|
|
327
|
+
results.push({ commandId, outcome: 'has-trace' });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const judged = judgeTrace(answer, nodeId, BigInt(coordination.fence));
|
|
331
|
+
if (!judged.settle) {
|
|
332
|
+
console.warn(`[resource-leases] lease ${commandId} stays held: ${judged.code}: ${judged.detail}`);
|
|
333
|
+
results.push({ commandId, outcome: 'unverified', code: judged.code, detail: judged.detail });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const evidence = createHash('sha256').update(`receipt-absent:${JSON.stringify(answer)}`).digest('hex');
|
|
337
|
+
const settled = await db.$transaction(async (tx) => {
|
|
338
|
+
await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`command-resource-node:${nodeId}`}, 0))`;
|
|
339
|
+
const changed = await tx.commandResourceLease.updateMany({ where: { id: row.id, state: 'unknown' },
|
|
340
|
+
data: { state: 'released', settledAt: new Date(), settlement: 'not-dispatched', evidenceSha256: evidence } });
|
|
341
|
+
if (!changed.count)
|
|
342
|
+
return false;
|
|
343
|
+
if (row.directCommand)
|
|
344
|
+
await tx.remoteCommand.updateMany({ where: { id: commandId, status: 'RUNNING' }, data: { status: 'FAILED', completedAt: new Date() } });
|
|
345
|
+
return true;
|
|
346
|
+
});
|
|
347
|
+
if (!settled)
|
|
348
|
+
continue;
|
|
349
|
+
await logAuditEvent({ action: 'agent.lease.recovered', actorId, actorType: 'system', targetType: 'node', targetId: nodeId,
|
|
350
|
+
details: { commandId, leaseId: row.id, settlement: 'not-dispatched', evidenceSha256: evidence, reason: 'the agent ledger holds no receipt for it after the lease deadline', fenceHighWater: answer.fenceHighWater, leaseFence: coordination.fence, activeLock: answer.activeLock } })
|
|
351
|
+
.catch((error) => console.warn(`[resource-leases] audit of ${commandId} failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
352
|
+
results.push({ commandId, outcome: 'settled' });
|
|
353
|
+
}
|
|
354
|
+
return results;
|
|
355
|
+
}
|
|
229
356
|
export async function retainUnknownResourceCommand(commandId) {
|
|
230
357
|
await getDb().$transaction(async (tx) => {
|
|
231
358
|
// Persisted 'reserved' proves the sole POST barrier was never crossed. This
|
|
@@ -222,6 +222,8 @@ export async function readParticipantReceipt(response, limit = PARTICIPANT_RECEI
|
|
|
222
222
|
await reader.cancel().catch(() => undefined);
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
|
+
/** The reply is a fixed code; what was wrong with the stored evidence goes to the log. */
|
|
226
|
+
const warnEvidence = (what, nodeId, error) => console.warn(`[resource-enrollment] ${what} of node ${nodeId} does not parse: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
227
|
export async function refreshEnrollmentParticipant(nodeId, participant) {
|
|
226
228
|
// Fixed inventories and origins only: operator input cannot choose an endpoint or key.
|
|
227
229
|
const prepared = await getDb().$transaction(async (tx) => {
|
|
@@ -328,7 +330,8 @@ export async function acceptSchedulerBoundaryAttestation(nodeId, raw) {
|
|
|
328
330
|
try {
|
|
329
331
|
receipt = schedulerBoundaryReceiptSchema.parse(JSON.parse(ack?.receiptJson ?? 'null'));
|
|
330
332
|
}
|
|
331
|
-
catch {
|
|
333
|
+
catch (error) {
|
|
334
|
+
warnEvidence('scheduler receipt', nodeId, error);
|
|
332
335
|
fail('SCHEDULER_BOUNDARY_EVIDENCE_INVALID', 'Current signed scheduler receipt is required');
|
|
333
336
|
}
|
|
334
337
|
const { signature: receiptSignature, ...receiptUnsigned } = receipt;
|
|
@@ -411,7 +414,8 @@ export async function completeEnrollment(nodeId) {
|
|
|
411
414
|
reviewed = z.object({ receipt: schedulerBoundaryReceiptSchema, assertion: schedulerBoundaryAttestationSchema }).strict()
|
|
412
415
|
.parse(JSON.parse(ack.receiptJson ?? 'null'));
|
|
413
416
|
}
|
|
414
|
-
catch {
|
|
417
|
+
catch (error) {
|
|
418
|
+
warnEvidence('reviewed scheduler receipt', nodeId, error);
|
|
415
419
|
fail('RESOURCE_DRAIN_INCOMPLETE', 'Reviewed scheduler receipt is unavailable');
|
|
416
420
|
}
|
|
417
421
|
const { receipt, assertion } = reviewed;
|
|
@@ -440,7 +444,8 @@ export async function completeEnrollment(nodeId) {
|
|
|
440
444
|
? apiDrainAttestationSchema.parse(JSON.parse(ack.receiptJson ?? 'null'))
|
|
441
445
|
: drainReceiptSchema.parse(JSON.parse(ack.receiptJson ?? 'null'));
|
|
442
446
|
}
|
|
443
|
-
catch {
|
|
447
|
+
catch (error) {
|
|
448
|
+
warnEvidence(`${participant} receipt`, nodeId, error);
|
|
444
449
|
fail('RESOURCE_DRAIN_INCOMPLETE', 'Stored participant receipt is unavailable');
|
|
445
450
|
}
|
|
446
451
|
const { signature, ...unsigned } = receipt;
|
|
@@ -487,7 +492,8 @@ export async function dashboardParticipantReadiness() {
|
|
|
487
492
|
await tx.resourceParticipantWork.findFirst({ select: { workId: true } });
|
|
488
493
|
}, { maxWait: 2_000, timeout: 3_000 });
|
|
489
494
|
}
|
|
490
|
-
catch {
|
|
495
|
+
catch (error) {
|
|
496
|
+
console.warn(`[resource-enrollment] participant schema check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
491
497
|
throw new HttpError(503, 'RESOURCE_PARTICIPANT_UNAVAILABLE', 'Durable participant schema is not ready');
|
|
492
498
|
}
|
|
493
499
|
return { version: 1, participant: 'dashboard', protocol: 'resource-enrollment-v1', ready: true };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/magnus-server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "Magnus server: the fleet control plane API (Hono, PostgreSQL, Redis) for nodes running magnus-agent, with optional Hive IAM, Hive Router, HiveMem, Hermes Agent and external security console integrations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"magnus",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"prepublishOnly": "npm run db:generate && npm run clean && npm run build"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@hivemagnus/magnus-contracts": "1.1.
|
|
87
|
+
"@hivemagnus/magnus-contracts": "1.1.8",
|
|
88
88
|
"@hono/node-server": "^2.1.5",
|
|
89
89
|
"@hono/swagger-ui": "^0.6.2",
|
|
90
90
|
"@prisma/adapter-pg": "7.10.1",
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
"ws": "^8.22.0",
|
|
103
103
|
"yaml": "2.9.1",
|
|
104
104
|
"zod": "^4.6.7",
|
|
105
|
-
"@hivemagnus/magnus-skills": "1.1.
|
|
105
|
+
"@hivemagnus/magnus-skills": "1.1.8",
|
|
106
106
|
"prisma": "^7.10.1"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|