@hivemagnus/magnus-server 1.1.2 → 1.1.4
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/agent-version.json +1 -1
- package/dist/cli/agents.js +103 -30
- package/dist/cli/args.js +10 -2
- package/dist/cli/auto-migrate.js +75 -17
- package/dist/cli/env-catalog.js +4 -1
- package/dist/cli/migrations.js +176 -15
- package/dist/cli.js +83 -8
- package/dist/index.js +1 -1
- package/dist/openapi/spec.js +36 -7
- package/dist/routes/agent-upgrades.js +28 -7
- package/dist/routes/heartbeat.js +2 -1
- package/dist/routes/resource-leases.js +5 -7
- package/dist/sdk/node-api-client.js +96 -4
- package/dist/server.js +1 -0
- package/dist/services/agent-upgrade-service.js +610 -150
- package/dist/services/command-resource-leases.js +13 -2
- package/dist/services/managed-cron.js +4 -7
- package/dist/services/node-issue-monitor.js +2 -0
- package/dist/services/resource-enrollment.js +73 -18
- package/dist/services/resource-ownership.js +20 -0
- package/dist/utils/runtime-file.js +25 -1
- package/package.json +3 -3
|
@@ -23,6 +23,17 @@ export class ResourceLeaseConflict extends HttpError {
|
|
|
23
23
|
constructor() { super(409, 'RESOURCE_LEASE_CONFLICT', 'Node or service has an unsettled command owner; reconcile its evidence before retrying'); }
|
|
24
24
|
}
|
|
25
25
|
export function resourceLeasesRequested() { return process.env['MAGNUS_RESOURCE_LEASES_ENABLED'] === 'true'; }
|
|
26
|
+
/** Online with a heartbeat at most this old: the freshness admission requires. */
|
|
27
|
+
export const RESOURCE_HEARTBEAT_FRESH_MS = 120_000;
|
|
28
|
+
/** Read-only predicates shared with upgrade eligibility, so it cannot drift from admission. */
|
|
29
|
+
export function hasFreshOnlineHealth(node, now = Date.now()) {
|
|
30
|
+
return node.status === 'ONLINE' && Boolean(node.lastHeartbeatAt) && now - node.lastHeartbeatAt.getTime() <= RESOURCE_HEARTBEAT_FRESH_MS;
|
|
31
|
+
}
|
|
32
|
+
/** Legacy unleased dispatch stays possible only while this holds AND the node has no lease history. */
|
|
33
|
+
export function legacyAdmissionPossible(enrollment, leasesRequested = resourceLeasesRequested()) {
|
|
34
|
+
return !leasesRequested && (!enrollment || enrollment.state === 'legacy');
|
|
35
|
+
}
|
|
36
|
+
export const HELD_RESOURCE_STATES = HELD;
|
|
26
37
|
function canonicalServiceUnit(value) {
|
|
27
38
|
// A narrow scope is granted only to the exact allowlisted immutable runbook.
|
|
28
39
|
// Arbitrary shell syntax, aliases, user units and wrapper commands stay node-wide.
|
|
@@ -96,7 +107,7 @@ export async function admitResourceCommand(context, raw) {
|
|
|
96
107
|
throw new HttpError(409, 'RESOURCE_TARGET_INVALID', 'Canonical command target differs from inventory');
|
|
97
108
|
await assertNodeAdmissionOpen(tx, node.id);
|
|
98
109
|
const enrollment = await tx.resourceEnrollment.findUnique({ where: { nodeId: node.id } });
|
|
99
|
-
if (!scopedArdent &&
|
|
110
|
+
if (!scopedArdent && legacyAdmissionPossible(enrollment)
|
|
100
111
|
&& !await tx.commandResourceLease.findFirst({ where: { nodeId: node.id }, select: { id: true } }))
|
|
101
112
|
return null;
|
|
102
113
|
if (scopedArdent)
|
|
@@ -106,7 +117,7 @@ export async function admitResourceCommand(context, raw) {
|
|
|
106
117
|
if (await tx.resourceParticipantWork.findFirst({ where: { nodeId: node.id,
|
|
107
118
|
...(scopedArdent ? { participant: { in: ['agent', 'api', 'dashboard'] } } : {}), state: { in: HELD } } }))
|
|
108
119
|
throw new ResourceLeaseConflict();
|
|
109
|
-
if (
|
|
120
|
+
if (!hasFreshOnlineHealth(node))
|
|
110
121
|
throw new HttpError(409, 'RESOURCE_TARGET_INVALID', 'Canonical command target lacks fresh online health');
|
|
111
122
|
if (await tx.commandResourceLease.findUnique({ where: { commandId: payload.commandId } }))
|
|
112
123
|
throw new HttpError(409, 'COMMAND_ALREADY_OWNED', 'Command already has a durable resource owner; inspect its receipt without replaying');
|
|
@@ -7,6 +7,7 @@ import { managedCronSchema, managedCronStageAckSchema, managedCronActivationAckS
|
|
|
7
7
|
import { lockResourceNode } from './resource-node-lock.js';
|
|
8
8
|
import { assertNodeAdmissionOpen } from './admission-fence.js';
|
|
9
9
|
import { assertEnrollmentComplete } from './resource-enrollment.js';
|
|
10
|
+
import { readOwnershipHistory, stickyEnrollmentObserved } from './resource-ownership.js';
|
|
10
11
|
import { admitResourceCommand, markResourceDispatched, settleResourceResult, resourceLeasesRequested } from './command-resource-leases.js';
|
|
11
12
|
import { commandRequestDigest } from '../security/command-request.js';
|
|
12
13
|
import { verifyCommandResultSignature } from '../security/command-result-signature.js';
|
|
@@ -33,14 +34,10 @@ export async function cronMutationMode(nodeId) {
|
|
|
33
34
|
const node = await tx.node.findUnique({ where: { id: nodeId } });
|
|
34
35
|
if (!node)
|
|
35
36
|
throw new HttpError(404, 'NODE_NOT_FOUND', 'Inventory node not found');
|
|
36
|
-
const [enrollment,
|
|
37
|
-
tx.resourceEnrollment.findUnique({ where: { nodeId }, select: { state: true } }),
|
|
38
|
-
tx.commandResourceLease.findFirst({ where: { nodeId }, select: { id: true } }),
|
|
39
|
-
tx.managedCronDefinition.findFirst({ where: { nodeId }, select: { cronJobId: true } }),
|
|
40
|
-
tx.resourceParticipantWork.findFirst({ where: { nodeId }, select: { workId: true } }),
|
|
37
|
+
const [enrollment, history] = await Promise.all([
|
|
38
|
+
tx.resourceEnrollment.findUnique({ where: { nodeId }, select: { state: true } }), readOwnershipHistory(tx, nodeId)
|
|
41
39
|
]);
|
|
42
|
-
const managed = resourceLeasesRequested() ||
|
|
43
|
-
|| Boolean(leaseHistory || definitionHistory || participantHistory);
|
|
40
|
+
const managed = resourceLeasesRequested() || stickyEnrollmentObserved(enrollment, history);
|
|
44
41
|
if (managed)
|
|
45
42
|
await assertEnrollmentComplete(tx, nodeId, node.hostname);
|
|
46
43
|
return managed ? 'managed' : 'legacy';
|
|
@@ -49,6 +49,8 @@ async function checkStaleNodes() {
|
|
|
49
49
|
},
|
|
50
50
|
}, maintenance?.id ?? null);
|
|
51
51
|
});
|
|
52
|
+
// An agent that went silent mid-upgrade must not stay UPDATING.
|
|
53
|
+
await import('./agent-upgrade-service.js').then(service => service.clearAgentUpdating(node.id)).catch(() => undefined);
|
|
52
54
|
}
|
|
53
55
|
recordMonitorPulse('nodes');
|
|
54
56
|
}
|
|
@@ -24,9 +24,13 @@ export function canonical(value) {
|
|
|
24
24
|
}
|
|
25
25
|
export function enrollmentSigningPayload(value) { return 'magnus.resource-enrollment.v1\n' + canonical(value); }
|
|
26
26
|
function fail(code, message) { throw new HttpError(409, code, message); }
|
|
27
|
+
/** Read-only predicate shared with upgrade eligibility: the enrollment row admits resource execution. */
|
|
28
|
+
export function enrollmentIsComplete(row, nodeFqdn) {
|
|
29
|
+
return Boolean(row) && row.state === 'enrolled' && row.nodeFqdn === nodeFqdn;
|
|
30
|
+
}
|
|
27
31
|
export async function assertEnrollmentComplete(tx, nodeId, nodeFqdn) {
|
|
28
32
|
const row = await tx.resourceEnrollment.findUnique({ where: { nodeId } });
|
|
29
|
-
if (!row
|
|
33
|
+
if (!enrollmentIsComplete(row, nodeFqdn))
|
|
30
34
|
fail('RESOURCE_ENROLLMENT_REQUIRED', 'Verified participant drain enrollment is required before resource execution');
|
|
31
35
|
}
|
|
32
36
|
export function dashboardEnrollmentSecret() {
|
|
@@ -105,7 +109,9 @@ export async function inspectEnrollment(nodeId) {
|
|
|
105
109
|
apiBlockers: row?.state === 'enrolled' || participants.some(p => p.participant === 'api' && p.status === 'verified' && p.epoch === row?.epoch
|
|
106
110
|
&& p.challengeExpiresAt.getTime() > Date.now()) ? [] :
|
|
107
111
|
['preinstrumentation-work-unverified', 'local-descendants-unverified', 'noninventory-bootstrap-unsupported', 'ssh-descendants-unverified'],
|
|
108
|
-
dashboard: await participantSnapshot(tx, nodeId, 'dashboard')
|
|
112
|
+
dashboard: await participantSnapshot(tx, nodeId, 'dashboard'),
|
|
113
|
+
// Since this process started; empty after a restart.
|
|
114
|
+
lastFailures: lastParticipantFailures(nodeId) };
|
|
109
115
|
});
|
|
110
116
|
}
|
|
111
117
|
export const drainDescriptorSchema = z.object({ version: z.literal(1), participant: z.enum(PARTICIPANTS),
|
|
@@ -145,11 +151,46 @@ export const apiDrainAttestationSchema = drainDescriptorSchema.extend({
|
|
|
145
151
|
export function apiDrainAttestationSigningPayload(value) {
|
|
146
152
|
return 'magnus.resource-enrollment-api-attestation.v1\n' + canonical(value);
|
|
147
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Response bounds. Drain receipts are small, except the scheduler's: it lists
|
|
156
|
+
* up to 1024 external blockers (schema maximum), each about 200 bytes of JSON
|
|
157
|
+
* (an 80-character kind, a 64-hex digest, a count and the keys), so a control
|
|
158
|
+
* host with many system timers needs about 210 KiB. 256 KiB covers that with
|
|
159
|
+
* margin and stays bounded.
|
|
160
|
+
*/
|
|
161
|
+
export const PARTICIPANT_RECEIPT_LIMITS = { default: 16_384, scheduler: 256 * 1024 };
|
|
162
|
+
/** A participant refresh that failed, as a fixed reason code (never the raw provider error or body). */
|
|
163
|
+
export class ParticipantEvidenceError extends Error {
|
|
164
|
+
code;
|
|
165
|
+
agentCode;
|
|
166
|
+
constructor(code, agentCode = null) {
|
|
167
|
+
super(code);
|
|
168
|
+
this.code = code;
|
|
169
|
+
this.agentCode = agentCode;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const failures = new Map();
|
|
173
|
+
/** The last refresh failure of each participant on a node, as seen by this server process. */
|
|
174
|
+
export function lastParticipantFailures(nodeId) {
|
|
175
|
+
return [...failures.entries()].filter(([key]) => key.startsWith(`${nodeId}\n`)).map(([, value]) => value);
|
|
176
|
+
}
|
|
148
177
|
/** Bounded response consumption, including dishonest or absent content-length. */
|
|
149
|
-
async function
|
|
178
|
+
export async function readParticipantReceipt(response, limit = PARTICIPANT_RECEIPT_LIMITS.default) {
|
|
150
179
|
if (response.status !== 200 || !response.body) {
|
|
151
|
-
|
|
152
|
-
|
|
180
|
+
// An optional machine-readable code from the participant (`{ error: { code } }`) is kept; nothing else is.
|
|
181
|
+
let agentCode = null;
|
|
182
|
+
if (response.body && response.status >= 400) {
|
|
183
|
+
const text = await response.text().catch(() => '');
|
|
184
|
+
try {
|
|
185
|
+
const code = JSON.parse(text.slice(0, 4096))?.error?.code;
|
|
186
|
+
if (typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(code))
|
|
187
|
+
agentCode = code;
|
|
188
|
+
}
|
|
189
|
+
catch { /* no code */ }
|
|
190
|
+
}
|
|
191
|
+
else
|
|
192
|
+
await response.body?.cancel().catch(() => undefined);
|
|
193
|
+
throw new ParticipantEvidenceError(`participant-http-${response.status}`, agentCode);
|
|
153
194
|
}
|
|
154
195
|
const reader = response.body.getReader();
|
|
155
196
|
const chunks = [];
|
|
@@ -160,11 +201,16 @@ async function readReceipt(response) {
|
|
|
160
201
|
if (done)
|
|
161
202
|
break;
|
|
162
203
|
size += value.byteLength;
|
|
163
|
-
if (size >
|
|
164
|
-
throw new
|
|
204
|
+
if (size > limit)
|
|
205
|
+
throw new ParticipantEvidenceError('participant-response-too-large');
|
|
165
206
|
chunks.push(value);
|
|
166
207
|
}
|
|
167
|
-
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
throw new ParticipantEvidenceError('participant-response-invalid');
|
|
213
|
+
}
|
|
168
214
|
}
|
|
169
215
|
finally {
|
|
170
216
|
await reader.cancel().catch(() => undefined);
|
|
@@ -197,9 +243,9 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
|
|
|
197
243
|
if (participant === 'agent' || participant === 'scheduler') {
|
|
198
244
|
key = RESULT_SIGNING_SECRET;
|
|
199
245
|
if (key.length < 32)
|
|
200
|
-
throw new
|
|
246
|
+
throw new ParticipantEvidenceError('receipt-credential-unavailable');
|
|
201
247
|
if (new URL(prepared.baseUrl).protocol !== 'https:')
|
|
202
|
-
throw new
|
|
248
|
+
throw new ParticipantEvidenceError('https-endpoint-required');
|
|
203
249
|
url = prepared.baseUrl.replace(/\/$/, '') + (participant === 'scheduler'
|
|
204
250
|
? '/api/v1/cron/enrollment-drain' : '/api/v1/commands/enrollment-drain');
|
|
205
251
|
headers = { 'x-node-token': getNodeManagerSecret() };
|
|
@@ -208,14 +254,18 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
|
|
|
208
254
|
key = dashboardEnrollmentSecret();
|
|
209
255
|
const origin = new URL(process.env['DASHBOARD_PUBLIC_ORIGIN'] ?? '');
|
|
210
256
|
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash)
|
|
211
|
-
throw new
|
|
257
|
+
throw new ParticipantEvidenceError('dashboard-origin-unavailable');
|
|
212
258
|
url = origin.origin + '/api/internal/resource-enrollment/drain';
|
|
213
259
|
headers = { 'x-resource-drain-key': key };
|
|
214
260
|
}
|
|
215
261
|
const response = await fetch(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(participant !== 'dashboard' ? 25_000 : 5_000),
|
|
216
|
-
headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(prepared.descriptor) })
|
|
217
|
-
|
|
218
|
-
|
|
262
|
+
headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(prepared.descriptor) })
|
|
263
|
+
.catch(() => { throw new ParticipantEvidenceError('participant-unreachable'); });
|
|
264
|
+
const raw = await readParticipantReceipt(response, participant === 'scheduler' ? PARTICIPANT_RECEIPT_LIMITS.scheduler : PARTICIPANT_RECEIPT_LIMITS.default);
|
|
265
|
+
const parsed = participant === 'scheduler' ? schedulerBoundaryReceiptSchema.safeParse(raw) : drainReceiptSchema.safeParse(raw);
|
|
266
|
+
if (!parsed.success)
|
|
267
|
+
throw new ParticipantEvidenceError('participant-receipt-invalid');
|
|
268
|
+
receipt = parsed.data;
|
|
219
269
|
const { signature, ...unsigned } = receipt;
|
|
220
270
|
const { status: _status, activeCount: _active, unknownCount: _unknown, evidenceSha256: _evidence, ...remaining } = unsigned;
|
|
221
271
|
const descriptor = participant === 'scheduler'
|
|
@@ -223,12 +273,17 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
|
|
|
223
273
|
: remaining;
|
|
224
274
|
if (canonical(descriptor) !== canonical(prepared.descriptor) || receipt.expiresAt <= Date.now()
|
|
225
275
|
|| !timingSafeEqual(Buffer.from(signature, 'hex'), createHmac('sha256', key).update(enrollmentSigningPayload(unsigned)).digest()))
|
|
226
|
-
throw new
|
|
276
|
+
throw new ParticipantEvidenceError('participant-receipt-unauthenticated');
|
|
227
277
|
}
|
|
228
|
-
catch {
|
|
229
|
-
// No raw provider error/body is exposed
|
|
230
|
-
|
|
278
|
+
catch (error) {
|
|
279
|
+
// No raw provider error/body is exposed, only a fixed cause (and the participant's own code, if it sent one).
|
|
280
|
+
// Outstanding challenge remains pending.
|
|
281
|
+
const cause = error instanceof ParticipantEvidenceError ? error.code : 'participant-evidence-unavailable';
|
|
282
|
+
const agentCode = error instanceof ParticipantEvidenceError ? error.agentCode : null;
|
|
283
|
+
failures.set(`${nodeId}\n${participant}`, { participant, cause, agentCode, at: new Date().toISOString() });
|
|
284
|
+
return { participant, status: 'pending', reason: 'participant-evidence-unavailable', cause, agentCode };
|
|
231
285
|
}
|
|
286
|
+
failures.delete(`${nodeId}\n${participant}`);
|
|
232
287
|
return getDb().$transaction(async (tx) => {
|
|
233
288
|
await lockResourceNode(tx, nodeId);
|
|
234
289
|
const { row } = await identity(tx, nodeId);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export async function readOwnershipHistory(tx, nodeId) {
|
|
2
|
+
const [lease, managedCron, participantWork] = await Promise.all([
|
|
3
|
+
tx.commandResourceLease.findFirst({ where: { nodeId }, select: { id: true } }),
|
|
4
|
+
tx.managedCronDefinition.findFirst({ where: { nodeId }, select: { cronJobId: true } }),
|
|
5
|
+
tx.resourceParticipantWork.findFirst({ where: { nodeId }, select: { workId: true } }),
|
|
6
|
+
]);
|
|
7
|
+
return { lease: Boolean(lease), managedCron: Boolean(managedCron), participantWork: Boolean(participantWork) };
|
|
8
|
+
}
|
|
9
|
+
/** The sticky rule: what legacy-admission tells an agent (`enrolled`) and what managed cron requires. */
|
|
10
|
+
export function stickyEnrollmentObserved(enrollment, history) {
|
|
11
|
+
return Boolean(enrollment && enrollment.state !== 'legacy') || history.lease || history.managedCron || history.participantWork;
|
|
12
|
+
}
|
|
13
|
+
/** Why the sticky rule holds, for operator-facing reasons. */
|
|
14
|
+
export function stickyEnrollmentCause(enrollment, history) {
|
|
15
|
+
const causes = [enrollment && enrollment.state !== 'legacy' ? `a ${enrollment.state} enrollment` : null,
|
|
16
|
+
history.lease ? 'command lease history' : null, history.managedCron ? 'managed cron history' : null,
|
|
17
|
+
history.participantWork ? 'participant work history' : null].filter(Boolean);
|
|
18
|
+
return causes.length ? causes.join(', ') : null;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=resource-ownership.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { magnusHome } from './magnus-home.js';
|
|
4
4
|
/**
|
|
@@ -8,6 +8,7 @@ import { magnusHome } from './magnus-home.js';
|
|
|
8
8
|
export function runtimeFilePath(env = process.env) {
|
|
9
9
|
return env['MAGNUS_RUNTIME_FILE']?.trim() || join(magnusHome(env), 'run', 'server-runtime.json');
|
|
10
10
|
}
|
|
11
|
+
/** `host` is the bind address, so tooling probes the address the server listens on. */
|
|
11
12
|
export function writeRuntimeFile(details, env = process.env) {
|
|
12
13
|
const path = runtimeFilePath(env);
|
|
13
14
|
try {
|
|
@@ -20,4 +21,27 @@ export function writeRuntimeFile(details, env = process.env) {
|
|
|
20
21
|
console.warn(`[startup] could not write ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
21
22
|
}
|
|
22
23
|
}
|
|
24
|
+
/** The server this runtime file names, when its process is still alive on this host. */
|
|
25
|
+
export function runningServer(env = process.env) {
|
|
26
|
+
const path = runtimeFilePath(env);
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
const info = JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
+
const pid = Number(info.pid);
|
|
32
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== 'EPERM')
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return { pid, version: String(info.version ?? 'unknown') };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
23
47
|
//# sourceMappingURL=runtime-file.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/magnus-server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
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.4",
|
|
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.4",
|
|
106
106
|
"prisma": "^7.10.1"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|