@hivemagnus/magnus-server 2.0.0 → 2.1.1
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 +32 -0
- package/README.md +32 -0
- package/agent-version.json +1 -1
- package/dist/ai/TOOLS.md +1 -1
- package/dist/cli/args.js +3 -1
- package/dist/cli/env-catalog.js +13 -0
- package/dist/cli/migrations.js +22 -0
- package/dist/cli/verify-initialization.js +12 -0
- package/dist/cli.js +28 -0
- package/dist/config/console-path.js +17 -0
- package/dist/config/env.js +28 -0
- package/dist/config/features.js +3 -0
- package/dist/config/oathweave.js +42 -0
- package/dist/config/rehearsal.js +22 -0
- package/dist/db/generated/prisma/internal/class.js +4 -4
- package/dist/db/generated/prisma/internal/prismaNamespace.js +5 -0
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +5 -0
- package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
- package/dist/db/read-retry.js +5 -1
- package/dist/db/seed.js +8 -0
- package/dist/index.js +27 -21
- package/dist/middleware/auth.js +21 -0
- package/dist/middleware/error-handler.js +10 -10
- package/dist/middleware/oathweave-verifier.js +23 -0
- package/dist/middleware/oathweave.js +115 -0
- package/dist/middleware/rate-limit.js +12 -3
- package/dist/openapi/spec.js +38 -2
- package/dist/queue/names.js +9 -0
- package/dist/queue/workers.js +9 -3
- package/dist/routes/assistant.js +30 -12
- package/dist/routes/console.js +33 -0
- package/dist/routes/cron.js +8 -5
- package/dist/routes/events-stream.js +10 -0
- package/dist/routes/files.js +2 -1
- package/dist/routes/fleet-gateway.js +69 -0
- package/dist/routes/hermes.js +26 -6
- package/dist/routes/hiveskills.js +116 -0
- package/dist/routes/library.js +3 -2
- package/dist/routes/logs.js +5 -0
- package/dist/routes/metrics.js +4 -3
- package/dist/routes/nodes.js +45 -8
- package/dist/routes/ops.js +24 -6
- package/dist/routes/profiling.js +2 -1
- package/dist/routes/roles.js +17 -9
- package/dist/routes/stats.js +8 -5
- package/dist/routes/terminal-ticket.js +3 -3
- package/dist/routes/terminal.js +2 -2
- package/dist/routes/webhooks.js +15 -15
- package/dist/sdk/node-api-client.js +11 -1
- package/dist/security/native-completion-registry.js +3 -1
- package/dist/server.js +8 -0
- package/dist/services/ai-assistant-service.js +32 -6
- package/dist/services/ai-tools.js +15 -8
- package/dist/services/ardent-scope-enrollment.js +4 -2
- package/dist/services/forecast-service.js +23 -8
- package/dist/services/ops-service.js +20 -25
- package/dist/services/prediction-service.js +2 -1
- package/dist/services/resource-enrollment.js +57 -4
- package/dist/services/secret-views.js +72 -0
- package/dist/services/topology-service.js +2 -1
- package/dist/services/webhook-security.js +10 -1
- package/dist/services/webhook-service.js +14 -6
- package/dist/utils/node-url.js +2 -1
- package/dist/utils/safe-errors.js +130 -0
- package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
- package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
- package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
- package/package.json +7 -3
- package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
- package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
- package/prisma/schema.prisma +9 -0
package/dist/routes/assistant.js
CHANGED
|
@@ -24,13 +24,25 @@ import { Hono } from 'hono';
|
|
|
24
24
|
import { streamSSE } from 'hono/streaming';
|
|
25
25
|
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
|
26
26
|
import { HttpError } from '../middleware/error-handler.js';
|
|
27
|
-
import {
|
|
27
|
+
import { clientErrorMessage } from '../utils/safe-errors.js';
|
|
28
|
+
import { chat, chatStream, listConversations, getConversationMessages, getUnreadAiMessageCount, markAiMessagesRead, markConversationRead, archiveConversation, deleteConversation, getHeartbeatPrompt, updateHeartbeatPrompt, resetHeartbeatPrompt, listOllamaModels, getModelContextLength, getAiSettings, updateAiSettings, getModelProfiles, updateModelProfiles, approveToolCall, rejectToolCall, createConversationShare, forkConversation, getSharedConversation, conversationOwners, getConversationAccess, } from '../services/ai-assistant-service.js';
|
|
28
29
|
import { executeAiTool } from '../services/ai-tools.js';
|
|
29
30
|
import { generateInsights, listPinnedInsights, pinInsight, unpinInsight } from '../services/ai-insights-service.js';
|
|
30
31
|
import { exportConversation } from '../services/ai-export-service.js';
|
|
31
32
|
import { clearScratchpad, listScratchpadEntries } from '../services/ai-scratchpad-service.js';
|
|
32
33
|
export const assistantRouter = new Hono();
|
|
33
34
|
assistantRouter.use('*', authMiddleware);
|
|
35
|
+
const ownersOf = (c) => conversationOwners(c.get('user'));
|
|
36
|
+
/**
|
|
37
|
+
* A conversation belongs to whoever started it. ADMIN gets no exception, because no
|
|
38
|
+
* console feature reads other people's conversations. Someone else's answers 404,
|
|
39
|
+
* so a caller cannot tell it exists.
|
|
40
|
+
*/
|
|
41
|
+
async function requireConversation(c, id, level = 'own') {
|
|
42
|
+
const access = await getConversationAccess(id, ownersOf(c));
|
|
43
|
+
if (!access || (level === 'own' && access !== 'owner'))
|
|
44
|
+
throw new HttpError(404, 'NOT_FOUND', 'Conversation not found');
|
|
45
|
+
}
|
|
34
46
|
// ── Send a chat message ──
|
|
35
47
|
assistantRouter.post('/chat', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
36
48
|
const body = await c.req.json();
|
|
@@ -44,6 +56,8 @@ assistantRouter.post('/chat', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
44
56
|
ctxParts.push(`You are the AI assistant for the "${body.controlNodeName}" Control Node (ID: ${body.controlNodeId ?? 'unknown'}). All fleet data you access is from this control node's database. When the user asks about "the fleet" or "nodes", they mean the nodes managed by this control node. Your tools (like getFleetStatus, getNodeHealth, etc.) query this control node's data directly — they do NOT require a fleet ID parameter.`);
|
|
45
57
|
controllerContext = ctxParts.join('\n');
|
|
46
58
|
}
|
|
59
|
+
if (body.conversationId)
|
|
60
|
+
await requireConversation(c, body.conversationId);
|
|
47
61
|
const user = c.get('user');
|
|
48
62
|
const result = await chat({
|
|
49
63
|
message: body.message,
|
|
@@ -67,28 +81,23 @@ assistantRouter.get('/conversations', async (c) => {
|
|
|
67
81
|
status: c.req.query('status') ?? 'active',
|
|
68
82
|
page: parseInt(c.req.query('page') ?? '1', 10),
|
|
69
83
|
pageSize: parseInt(c.req.query('pageSize') ?? '20', 10),
|
|
84
|
+
owners: ownersOf(c),
|
|
70
85
|
});
|
|
71
86
|
return c.json({ success: true, data: result });
|
|
72
87
|
});
|
|
73
88
|
// ── Get conversation messages ──
|
|
74
89
|
assistantRouter.get('/conversations/:id', async (c) => {
|
|
75
90
|
const id = c.req.param('id');
|
|
91
|
+
await requireConversation(c, id, 'read');
|
|
76
92
|
const result = await getConversationMessages(id, {
|
|
77
93
|
page: parseInt(c.req.query('page') ?? '1', 10),
|
|
78
94
|
pageSize: parseInt(c.req.query('pageSize') ?? '50', 10),
|
|
79
95
|
});
|
|
80
|
-
if (result.total === 0) {
|
|
81
|
-
// Check if conversation exists
|
|
82
|
-
const { getDb } = await import('../db/index.js');
|
|
83
|
-
const db = getDb();
|
|
84
|
-
const conv = await db.aiConversation.findUnique({ where: { id } });
|
|
85
|
-
if (!conv)
|
|
86
|
-
throw new HttpError(404, 'NOT_FOUND', 'Conversation not found');
|
|
87
|
-
}
|
|
88
96
|
return c.json({ success: true, data: result });
|
|
89
97
|
});
|
|
90
98
|
assistantRouter.post('/conversations/:id/fork', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
91
99
|
const id = c.req.param('id');
|
|
100
|
+
await requireConversation(c, id, 'read');
|
|
92
101
|
const body = (await c.req.json().catch(() => ({})));
|
|
93
102
|
const user = c.get('user');
|
|
94
103
|
try {
|
|
@@ -107,6 +116,7 @@ assistantRouter.post('/conversations/:id/fork', requireRole('ADMIN', 'OPERATOR')
|
|
|
107
116
|
});
|
|
108
117
|
assistantRouter.post('/conversations/:id/share', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
109
118
|
const id = c.req.param('id');
|
|
119
|
+
await requireConversation(c, id);
|
|
110
120
|
const body = (await c.req.json().catch(() => ({})));
|
|
111
121
|
const user = c.get('user');
|
|
112
122
|
try {
|
|
@@ -123,6 +133,7 @@ assistantRouter.post('/conversations/:id/share', requireRole('ADMIN', 'OPERATOR'
|
|
|
123
133
|
}
|
|
124
134
|
});
|
|
125
135
|
assistantRouter.get('/conversations/:id/scratchpad', async (c) => {
|
|
136
|
+
await requireConversation(c, c.req.param('id'), 'read');
|
|
126
137
|
const entries = listScratchpadEntries(c.req.param('id')).map((entry) => ({
|
|
127
138
|
name: entry.name,
|
|
128
139
|
source: entry.source,
|
|
@@ -134,6 +145,7 @@ assistantRouter.get('/conversations/:id/scratchpad', async (c) => {
|
|
|
134
145
|
return c.json({ success: true, data: { entries } });
|
|
135
146
|
});
|
|
136
147
|
assistantRouter.delete('/conversations/:id/scratchpad', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
148
|
+
await requireConversation(c, c.req.param('id'));
|
|
137
149
|
clearScratchpad(c.req.param('id'));
|
|
138
150
|
return c.json({ success: true });
|
|
139
151
|
});
|
|
@@ -147,12 +159,14 @@ assistantRouter.get('/shares/:token', async (c) => {
|
|
|
147
159
|
// ── Mark conversation as read ──
|
|
148
160
|
assistantRouter.post('/conversations/:id/read', async (c) => {
|
|
149
161
|
const id = c.req.param('id');
|
|
162
|
+
await requireConversation(c, id, 'read');
|
|
150
163
|
const count = await markConversationRead(id);
|
|
151
164
|
return c.json({ success: true, data: { marked: count } });
|
|
152
165
|
});
|
|
153
166
|
// ── Archive a conversation ──
|
|
154
167
|
assistantRouter.post('/conversations/:id/archive', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
155
168
|
const id = c.req.param('id');
|
|
169
|
+
await requireConversation(c, id);
|
|
156
170
|
try {
|
|
157
171
|
await archiveConversation(id);
|
|
158
172
|
return c.json({ success: true });
|
|
@@ -163,7 +177,7 @@ assistantRouter.post('/conversations/:id/archive', requireRole('ADMIN', 'OPERATO
|
|
|
163
177
|
});
|
|
164
178
|
// ── Get unread message count ──
|
|
165
179
|
assistantRouter.get('/unread', async (c) => {
|
|
166
|
-
const count = await getUnreadAiMessageCount();
|
|
180
|
+
const count = await getUnreadAiMessageCount(ownersOf(c));
|
|
167
181
|
return c.json({ success: true, data: { unread: count } });
|
|
168
182
|
});
|
|
169
183
|
// ── Mark specific messages as read ──
|
|
@@ -172,12 +186,13 @@ assistantRouter.post('/messages/read', async (c) => {
|
|
|
172
186
|
if (!body.messageIds?.length) {
|
|
173
187
|
throw new HttpError(400, 'VALIDATION_ERROR', 'messageIds array is required');
|
|
174
188
|
}
|
|
175
|
-
const count = await markAiMessagesRead(body.messageIds);
|
|
189
|
+
const count = await markAiMessagesRead(body.messageIds, ownersOf(c));
|
|
176
190
|
return c.json({ success: true, data: { marked: count } });
|
|
177
191
|
});
|
|
178
192
|
// ── Delete a conversation permanently ──
|
|
179
193
|
assistantRouter.delete('/conversations/:id', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
180
194
|
const id = c.req.param('id');
|
|
195
|
+
await requireConversation(c, id);
|
|
181
196
|
try {
|
|
182
197
|
await deleteConversation(id);
|
|
183
198
|
return c.json({ success: true });
|
|
@@ -236,6 +251,8 @@ assistantRouter.post('/chat/stream', requireRole('ADMIN', 'OPERATOR'), async (c)
|
|
|
236
251
|
ctxParts.push(`You are the AI assistant for the "${body.controlNodeName}" Control Node (ID: ${body.controlNodeId ?? 'unknown'}).`);
|
|
237
252
|
controllerContext = ctxParts.join('\n');
|
|
238
253
|
}
|
|
254
|
+
if (body.conversationId)
|
|
255
|
+
await requireConversation(c, body.conversationId);
|
|
239
256
|
const user = c.get('user');
|
|
240
257
|
return streamSSE(c, async (stream) => {
|
|
241
258
|
try {
|
|
@@ -261,7 +278,7 @@ assistantRouter.post('/chat/stream', requireRole('ADMIN', 'OPERATOR'), async (c)
|
|
|
261
278
|
catch (err) {
|
|
262
279
|
await stream.writeSSE({
|
|
263
280
|
event: 'error',
|
|
264
|
-
data: JSON.stringify({ type: 'error', error: err
|
|
281
|
+
data: JSON.stringify({ type: 'error', error: clientErrorMessage(err, 'Stream failed') }),
|
|
265
282
|
});
|
|
266
283
|
}
|
|
267
284
|
});
|
|
@@ -352,6 +369,7 @@ assistantRouter.post('/messages/:id/confirm', requireRole('ADMIN', 'OPERATOR'),
|
|
|
352
369
|
// ── Conversation export ──
|
|
353
370
|
assistantRouter.get('/conversations/:id/export', async (c) => {
|
|
354
371
|
const id = c.req.param('id');
|
|
372
|
+
await requireConversation(c, id, 'read');
|
|
355
373
|
const format = (c.req.query('format') ?? 'markdown');
|
|
356
374
|
if (!['markdown', 'json', 'csv'].includes(format)) {
|
|
357
375
|
throw new HttpError(400, 'VALIDATION_ERROR', 'format must be markdown, json, or csv');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { authMiddleware, getUser } from '../middleware/auth.js';
|
|
3
|
+
import { AGENT_VERSION, CONTROL_API_VERSION } from '../config/version.js';
|
|
4
|
+
import { featureFlags } from '../config/features.js';
|
|
5
|
+
import { securityConsoleUrl } from '../services/security-export.js';
|
|
6
|
+
export const consoleRouter = new Hono();
|
|
7
|
+
consoleRouter.use('*', authMiddleware);
|
|
8
|
+
/**
|
|
9
|
+
* GET /console/session
|
|
10
|
+
* What a browser console may show this caller. A static page has no server of its
|
|
11
|
+
* own to read the environment, so this answers what the dashboard derives from
|
|
12
|
+
* server-side checks and build-time public values.
|
|
13
|
+
*/
|
|
14
|
+
consoleRouter.get('/session', (c) => {
|
|
15
|
+
const user = getUser(c);
|
|
16
|
+
c.header('Cache-Control', 'no-store');
|
|
17
|
+
return c.json({
|
|
18
|
+
success: true,
|
|
19
|
+
data: {
|
|
20
|
+
userId: user.userId,
|
|
21
|
+
role: user.role,
|
|
22
|
+
capabilities: {
|
|
23
|
+
hermes: featureFlags().hermes,
|
|
24
|
+
// Whether to offer the terminal. The gateway still verifies everything, including step-up.
|
|
25
|
+
terminal: featureFlags().fleetGateway && user.terminal === true,
|
|
26
|
+
agentVersion: AGENT_VERSION,
|
|
27
|
+
magnusVersion: CONTROL_API_VERSION,
|
|
28
|
+
securityConsoleUrl: securityConsoleUrl() ?? null,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
//# sourceMappingURL=console.js.map
|
package/dist/routes/cron.js
CHANGED
|
@@ -2,6 +2,7 @@ import { managedCronRouter } from './managed-cron.js';
|
|
|
2
2
|
import { canStageLegacyCronForEnrollment, cronMutationMode, finalizeManagedCronRevocation, installManagedCron, registerManagedCron, reconcileForeignSchedulerSource, revokeManagedCron, stageLegacyCronForEnrollment, updateAndRegisterManagedCron } from '../services/managed-cron.js';
|
|
3
3
|
import { managedCronRegistrationSchema, schedulerSourceSettlementSchema } from '../schemas/managed-cron.js';
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
|
+
import { cronJobForRole, mergeEnvironmentEdit, nodeForRole } from '../services/secret-views.js';
|
|
5
6
|
import { getDb } from '../db/index.js';
|
|
6
7
|
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
7
8
|
import { logAuditEvent, sanitizeDetails } from '../services/audit-service.js';
|
|
@@ -68,7 +69,8 @@ cronRouter.get('/', async (c) => {
|
|
|
68
69
|
orderBy: { name: 'asc' },
|
|
69
70
|
include: { node: { select: { name: true } } },
|
|
70
71
|
});
|
|
71
|
-
|
|
72
|
+
const viewer = getUser(c).role;
|
|
73
|
+
return c.json({ success: true, data: jobs.map((job) => cronJobForRole(job, viewer)) });
|
|
72
74
|
});
|
|
73
75
|
// ── Get single cron job ──
|
|
74
76
|
cronRouter.get('/:cronId', async (c) => {
|
|
@@ -79,7 +81,7 @@ cronRouter.get('/:cronId', async (c) => {
|
|
|
79
81
|
});
|
|
80
82
|
if (!job)
|
|
81
83
|
throw new HttpError(404, 'NOT_FOUND', 'Cron job not found');
|
|
82
|
-
return c.json({ success: true, data: job });
|
|
84
|
+
return c.json({ success: true, data: cronJobForRole(job, getUser(c).role) });
|
|
83
85
|
});
|
|
84
86
|
// ── Create cron job ──
|
|
85
87
|
cronRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
@@ -136,7 +138,7 @@ cronRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
136
138
|
timeoutSeconds: body.timeoutSeconds ?? 600,
|
|
137
139
|
});
|
|
138
140
|
}
|
|
139
|
-
return c.json({ success: true, data: job }, 201);
|
|
141
|
+
return c.json({ success: true, data: cronJobForRole(job, user.role) }, 201);
|
|
140
142
|
});
|
|
141
143
|
cronRouter.post('/scheduler-sources/reconcile', requireRole('ADMIN'), async (c) => {
|
|
142
144
|
const assertion = await parseBody(c, schedulerSourceSettlementSchema);
|
|
@@ -209,7 +211,7 @@ cronRouter.patch('/:cronId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
209
211
|
...(body.command && { command: body.command }),
|
|
210
212
|
...(body.args && { args: JSON.stringify(body.args) }),
|
|
211
213
|
...(body.workingDirectory !== undefined && { workingDirectory: body.workingDirectory }),
|
|
212
|
-
...(body.environment !== undefined && { environment: JSON.stringify(sanitizeDetails(body.environment)) }),
|
|
214
|
+
...(body.environment !== undefined && { environment: JSON.stringify(sanitizeDetails(user.role === 'ADMIN' ? body.environment : mergeEnvironmentEdit(initial.environment, body.environment))) }),
|
|
213
215
|
...(nextStatus && { status: nextStatus }),
|
|
214
216
|
...(body.timeoutSeconds && { timeoutSeconds: body.timeoutSeconds }),
|
|
215
217
|
};
|
|
@@ -245,7 +247,8 @@ cronRouter.patch('/:cronId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
245
247
|
timeoutSeconds: job.timeoutSeconds,
|
|
246
248
|
});
|
|
247
249
|
})();
|
|
248
|
-
|
|
250
|
+
// The agent connection above needs the node; its token must never leave the server.
|
|
251
|
+
return c.json({ success: true, data: { ...cronJobForRole(job, user.role), node: nodeForRole(job.node, user.role) } });
|
|
249
252
|
});
|
|
250
253
|
// ── Delete cron job ──
|
|
251
254
|
cronRouter.delete('/:cronId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { authMiddleware } from '../middleware/auth.js';
|
|
3
|
+
import { watchOathweaveStream } from '../middleware/oathweave.js';
|
|
3
4
|
import { eventBus } from '../services/event-bus.js';
|
|
4
5
|
export const eventsStreamRouter = new Hono();
|
|
5
6
|
eventsStreamRouter.use('*', authMiddleware);
|
|
@@ -19,10 +20,12 @@ eventsStreamRouter.get('/', async (c) => {
|
|
|
19
20
|
let keepAlive;
|
|
20
21
|
let unsubscribe;
|
|
21
22
|
let closed = false;
|
|
23
|
+
let stopWatching;
|
|
22
24
|
// Idempotent cleanup for every exit path: client abort, stream cancel, or a
|
|
23
25
|
// disconnect that happened while the subscription was still being set up.
|
|
24
26
|
const cleanup = () => {
|
|
25
27
|
closed = true;
|
|
28
|
+
stopWatching?.();
|
|
26
29
|
if (keepAlive)
|
|
27
30
|
clearInterval(keepAlive);
|
|
28
31
|
keepAlive = undefined;
|
|
@@ -42,6 +45,13 @@ eventsStreamRouter.get('/', async (c) => {
|
|
|
42
45
|
// Stream closed
|
|
43
46
|
}
|
|
44
47
|
};
|
|
48
|
+
stopWatching = watchOathweaveStream(c.req.header('Authorization'), () => {
|
|
49
|
+
cleanup();
|
|
50
|
+
try {
|
|
51
|
+
controller.close();
|
|
52
|
+
}
|
|
53
|
+
catch { /* already closed */ }
|
|
54
|
+
});
|
|
45
55
|
// Send initial connection event
|
|
46
56
|
send('connected', { timestamp: new Date().toISOString() });
|
|
47
57
|
// Keep-alive every 30s
|
package/dist/routes/files.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clientErrorMessage } from '../utils/safe-errors.js';
|
|
1
2
|
import { Hono } from 'hono';
|
|
2
3
|
import { getDb } from '../db/index.js';
|
|
3
4
|
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
@@ -133,7 +134,7 @@ filesRouter.post('/update', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
133
134
|
return { ...task, status: 'completed' };
|
|
134
135
|
}
|
|
135
136
|
catch (err) {
|
|
136
|
-
const message = err
|
|
137
|
+
const message = clientErrorMessage(err, 'File update failed');
|
|
137
138
|
await db.filePatchTask.update({
|
|
138
139
|
where: { id: task.id },
|
|
139
140
|
data: { status: 'failed', error: message },
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { getDb } from '../db/index.js';
|
|
5
|
+
import { HttpError } from '../middleware/error-handler.js';
|
|
6
|
+
import { parseBody } from '../utils/validate.js';
|
|
7
|
+
import { safeEqual } from '../utils/safe-equal.js';
|
|
8
|
+
import { logAuditEvent } from '../services/audit-service.js';
|
|
9
|
+
/**
|
|
10
|
+
* Machine routes for the fleet gateway, the separate service that holds the restricted SSH key.
|
|
11
|
+
* The gateway asks what a node is and records what happened. Nothing here dials a node.
|
|
12
|
+
*/
|
|
13
|
+
export const fleetGatewayRouter = new Hono();
|
|
14
|
+
async function requireGatewayCredential(c, next) {
|
|
15
|
+
const expected = process.env['FLEET_GATEWAY_TOKEN_SHA256'] ?? '';
|
|
16
|
+
const token = c.req.header('x-fleet-gateway-token') ?? '';
|
|
17
|
+
if (!expected || !token || !safeEqual(createHash('sha256').update(token).digest('hex'), expected))
|
|
18
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Fleet gateway credential required');
|
|
19
|
+
await next();
|
|
20
|
+
}
|
|
21
|
+
fleetGatewayRouter.use('*', requireGatewayCredential);
|
|
22
|
+
const nodeId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/);
|
|
23
|
+
/** POST /fleet-gateway/target: inventory facts for one node. The gateway applies its own host-key approval. */
|
|
24
|
+
fleetGatewayRouter.post('/target', async (c) => {
|
|
25
|
+
const body = await parseBody(c, z.object({ nodeId }).strict());
|
|
26
|
+
const node = await getDb().node.findUnique({
|
|
27
|
+
where: { id: body.nodeId },
|
|
28
|
+
select: { id: true, hostname: true, ipAddress: true, status: true },
|
|
29
|
+
});
|
|
30
|
+
if (!node)
|
|
31
|
+
throw new HttpError(404, 'NODE_NOT_FOUND', 'Node not found');
|
|
32
|
+
c.header('Cache-Control', 'no-store');
|
|
33
|
+
return c.json({ success: true, data: { nodeId: node.id, hostname: node.hostname, ipAddress: node.ipAddress, status: node.status } });
|
|
34
|
+
});
|
|
35
|
+
const auditSchema = z.object({
|
|
36
|
+
action: z.enum(['terminal.session.open', 'terminal.session.close', 'terminal.session.refuse', 'terminal.host-key.approve', 'terminal.host-key.remove']),
|
|
37
|
+
subject: z.string().min(1).max(256),
|
|
38
|
+
actorId: z.string().min(1).max(256),
|
|
39
|
+
nodeId: nodeId.nullable(),
|
|
40
|
+
sessionId: z.string().regex(/^ts_[A-Za-z0-9_-]{43}$/).nullable(),
|
|
41
|
+
outcome: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9:._/-]{0,199}$/),
|
|
42
|
+
at: z.iso.datetime(),
|
|
43
|
+
durationMs: z.number().int().nonnegative().nullable(),
|
|
44
|
+
ipAddress: z.union([z.ipv4(), z.ipv6()]).nullable(),
|
|
45
|
+
fingerprint: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}$/).nullable(),
|
|
46
|
+
}).strict();
|
|
47
|
+
/** POST /fleet-gateway/audit: terminal metadata. The schema has no field for terminal input or output. */
|
|
48
|
+
fleetGatewayRouter.post('/audit', async (c) => {
|
|
49
|
+
const event = await parseBody(c, auditSchema);
|
|
50
|
+
await logAuditEvent({
|
|
51
|
+
action: event.action,
|
|
52
|
+
actorId: event.actorId,
|
|
53
|
+
actorType: 'user',
|
|
54
|
+
targetType: 'node',
|
|
55
|
+
targetId: event.nodeId ?? 'unspecified',
|
|
56
|
+
ipAddress: event.ipAddress,
|
|
57
|
+
details: {
|
|
58
|
+
subject: event.subject,
|
|
59
|
+
sessionHandle: event.sessionId,
|
|
60
|
+
outcome: event.outcome,
|
|
61
|
+
observedAt: event.at,
|
|
62
|
+
durationMs: event.durationMs,
|
|
63
|
+
hostKeyFingerprint: event.fingerprint,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
c.header('Cache-Control', 'no-store');
|
|
67
|
+
return c.json({ success: true, data: { recorded: true } });
|
|
68
|
+
});
|
|
69
|
+
//# sourceMappingURL=fleet-gateway.js.map
|
package/dist/routes/hermes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import { authMiddleware } from '../middleware/auth.js';
|
|
2
|
+
import { authMiddleware, getUser } from '../middleware/auth.js';
|
|
3
|
+
import { hermesProfileForRole } from '../services/secret-views.js';
|
|
3
4
|
import { HttpError } from '../middleware/error-handler.js';
|
|
4
5
|
import { hermesJobListQuerySchema } from '../schemas/hermes.js';
|
|
5
6
|
import { getHermesApiServerStatus, getHermesInstallation, getHermesJob, listHermesInstallations, listHermesJobs, listHermesProfiles, } from '../services/hermes-service.js';
|
|
@@ -13,21 +14,40 @@ function requiredParam(c, name) {
|
|
|
13
14
|
}
|
|
14
15
|
// Read-only inventory/status endpoints remain available because they use the
|
|
15
16
|
// Control database and never open SSH connections.
|
|
16
|
-
|
|
17
|
+
function installationForRole(installation, role) {
|
|
18
|
+
return installation.profiles
|
|
19
|
+
? { ...installation, profiles: installation.profiles.map((profile) => hermesProfileForRole(profile, role)) }
|
|
20
|
+
: installation;
|
|
21
|
+
}
|
|
22
|
+
hermesRouter.get('/installations', async (c) => {
|
|
23
|
+
const role = getUser(c).role;
|
|
24
|
+
return c.json({ success: true, data: (await listHermesInstallations()).map((installation) => installationForRole(installation, role)) });
|
|
25
|
+
});
|
|
17
26
|
hermesRouter.get('/installations/:id', async (c) => {
|
|
18
27
|
const data = await getHermesInstallation(requiredParam(c, 'id'));
|
|
19
28
|
if (!data)
|
|
20
29
|
throw new HttpError(404, 'NOT_FOUND', 'Hermes installation not found');
|
|
21
|
-
return c.json({ success: true, data });
|
|
30
|
+
return c.json({ success: true, data: installationForRole(data, getUser(c).role) });
|
|
22
31
|
});
|
|
23
32
|
hermesRouter.get('/installations/:id/profiles', async (c) => {
|
|
24
33
|
const installation = await getHermesInstallation(requiredParam(c, 'id'));
|
|
25
34
|
if (!installation)
|
|
26
35
|
throw new HttpError(404, 'NOT_FOUND', 'Hermes installation not found');
|
|
27
|
-
|
|
36
|
+
const role = getUser(c).role;
|
|
37
|
+
return c.json({ success: true, data: (await listHermesProfiles(installation.id)).map((profile) => hermesProfileForRole(profile, role)) });
|
|
38
|
+
});
|
|
39
|
+
hermesRouter.get('/profiles', async (c) => {
|
|
40
|
+
const role = getUser(c).role;
|
|
41
|
+
return c.json({ success: true, data: (await listHermesProfiles()).map((profile) => hermesProfileForRole(profile, role)) });
|
|
42
|
+
});
|
|
43
|
+
hermesRouter.get('/profiles/:id/api-server/status', async (c) => {
|
|
44
|
+
const { keyFingerprint, ...status } = await getHermesApiServerStatus(requiredParam(c, 'id'));
|
|
45
|
+
return c.json({ success: true, data: {
|
|
46
|
+
...status,
|
|
47
|
+
...(getUser(c).role === 'ADMIN' ? { keyFingerprint } : {}),
|
|
48
|
+
keyConfigured: Boolean(keyFingerprint),
|
|
49
|
+
} });
|
|
28
50
|
});
|
|
29
|
-
hermesRouter.get('/profiles', async (c) => c.json({ success: true, data: await listHermesProfiles() }));
|
|
30
|
-
hermesRouter.get('/profiles/:id/api-server/status', async (c) => c.json({ success: true, data: await getHermesApiServerStatus(requiredParam(c, 'id')) }));
|
|
31
51
|
hermesRouter.get('/jobs', async (c) => {
|
|
32
52
|
const parsed = hermesJobListQuerySchema.safeParse({
|
|
33
53
|
status: c.req.query('status'),
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { HiveSkillsTargetPostureSchema, HiveSkillsPublicationSchema } from '@hivemagnus/magnus-contracts';
|
|
5
|
+
import { getDb } from '../db/index.js';
|
|
6
|
+
import { HttpError } from '../middleware/error-handler.js';
|
|
7
|
+
import { safeEqual } from '../utils/safe-equal.js';
|
|
8
|
+
import { getNodeManagerSecret } from '../utils/env.js';
|
|
9
|
+
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
10
|
+
import { NodeApiClient } from '../sdk/node-api-client.js';
|
|
11
|
+
import { logAuditEvent } from '../services/audit-service.js';
|
|
12
|
+
import { httpIdentity } from '../security/managed-http.js';
|
|
13
|
+
const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{1,62}$/);
|
|
14
|
+
export const skillsExecutorConfig = z.record(slug, z.object({ tokenSha256: z.string().regex(/^[a-f0-9]{64}$/), targets: z.record(slug, z.object({ nodeId: z.string().min(1).max(128), agentTarget: slug }).strict()) }).strict());
|
|
15
|
+
export const skillJobRequest = z.object({ target: slug, publicationSha256: z.string().regex(/^[a-f0-9]{64}$/), manifest: HiveSkillsPublicationSchema, approvedBy: z.string().min(1).max(200) }).strict();
|
|
16
|
+
export const hiveskillsRouter = new Hono();
|
|
17
|
+
function authorize(header) {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = JSON.parse(process.env['MAGNUS_HIVESKILLS_DEPLOYMENTS_JSON'] ?? '{}');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new HttpError(503, 'HIVESKILLS_UNCONFIGURED', 'Invalid Skills executor configuration');
|
|
24
|
+
}
|
|
25
|
+
const configured = skillsExecutorConfig.safeParse(raw);
|
|
26
|
+
if (!configured.success)
|
|
27
|
+
throw new HttpError(503, 'HIVESKILLS_UNCONFIGURED', 'Invalid Skills executor configuration');
|
|
28
|
+
if (!header?.startsWith('Bearer ') || header.length < 39 || /[\r\n]/.test(header))
|
|
29
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Endpoint-scoped Skills credential required');
|
|
30
|
+
const digest = createHash('sha256').update(header.slice(7)).digest('hex');
|
|
31
|
+
for (const [registryId, entry] of Object.entries(configured.data))
|
|
32
|
+
if (safeEqual(entry.tokenSha256, digest))
|
|
33
|
+
return { registryId, ...entry };
|
|
34
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Endpoint-scoped Skills credential required');
|
|
35
|
+
}
|
|
36
|
+
function commandId(registryId, id) { if (!z.uuid().safeParse(id).success)
|
|
37
|
+
throw new HttpError(400, 'VALIDATION_ERROR', 'Job UUID required'); return 'hiveskills-' + createHash('sha256').update(registryId + ':' + id).digest('hex'); }
|
|
38
|
+
async function targetClient(registry, target) {
|
|
39
|
+
const binding = registry.targets[target];
|
|
40
|
+
if (!binding)
|
|
41
|
+
throw new HttpError(403, 'FORBIDDEN', 'Target is outside this registry executor grant');
|
|
42
|
+
const node = await getDb().node.findUnique({ where: { id: binding.nodeId } });
|
|
43
|
+
if (!node || node.status === 'DECOMMISSIONED')
|
|
44
|
+
throw new HttpError(409, 'TARGET_UNAVAILABLE', 'Configured target is unavailable');
|
|
45
|
+
return { binding, node, client: new NodeApiClient({ baseUrl: getNodeBaseUrl(node.ipAddress, node.port), resourceNodeId: node.id, resourceActorId: 'service:hiveskills:' + registry.registryId, nodeToken: getNodeManagerSecret() }) };
|
|
46
|
+
}
|
|
47
|
+
async function observed(registry, target, id) {
|
|
48
|
+
const { node, client } = await targetClient(registry, target), jobId = commandId(registry.registryId, id);
|
|
49
|
+
const row = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
|
|
50
|
+
if (!row)
|
|
51
|
+
throw new HttpError(404, 'NOT_FOUND', 'Job has not been admitted');
|
|
52
|
+
if (row.nodeId !== node.id || row.createdBy !== 'service:hiveskills:' + registry.registryId)
|
|
53
|
+
throw new HttpError(409, 'JOB_BINDING_CONFLICT', 'Job belongs to another target');
|
|
54
|
+
const lease = await getDb().commandResourceLease.findUnique({ where: { commandId: jobId } });
|
|
55
|
+
if (!lease)
|
|
56
|
+
return { jobId, status: 'unknown', result: 'Missing durable ownership; review required' };
|
|
57
|
+
try {
|
|
58
|
+
const receipt = await client.getCommandReceipt(jobId, { nodeFqdn: node.hostname, requestSha256: lease.requestSha256 });
|
|
59
|
+
if (receipt.state === 'completed')
|
|
60
|
+
return { jobId, status: receipt.result.exitCode === 0 ? 'completed' : 'failed', result: receipt.result.exitCode === 0 ? 'Exact approved publication synchronized' : 'Node completed with a bounded failure' };
|
|
61
|
+
return { jobId, status: receipt.state === 'running' ? 'running' : 'unknown', result: 'Original operation retained; never replayed' };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { jobId, status: 'unknown', result: 'Receipt unavailable; original operation retained' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
hiveskillsRouter.get('/targets/:target', async (c) => {
|
|
68
|
+
const registry = authorize(c.req.header('authorization')), target = slug.safeParse(c.req.param('target'));
|
|
69
|
+
if (!target.success)
|
|
70
|
+
throw new HttpError(400, 'VALIDATION_ERROR', 'Exact logical target required');
|
|
71
|
+
const { binding, node, client } = await targetClient(registry, target.data), enrollment = await getDb().resourceEnrollment.findUnique({ where: { nodeId: node.id } });
|
|
72
|
+
const base = { registryId: registry.registryId, target: target.data, nodeId: node.id, agentTarget: binding.agentTarget };
|
|
73
|
+
if (enrollment?.state !== 'enrolled' || enrollment.nodeFqdn !== node.hostname || node.status !== 'ONLINE' || !node.lastHeartbeatAt || Date.now() - node.lastHeartbeatAt.getTime() > 120000 || await getDb().commandResourceLease.count({ where: { nodeId: node.id, state: { in: ['reserved', 'running', 'unknown'] } } }))
|
|
74
|
+
return c.json({ ...base, ready: false, reason: 'Node enrollment, health or retained ownership requires operator review' });
|
|
75
|
+
try {
|
|
76
|
+
const reply = await client.hiveSkillsTargetPosture(binding.agentTarget, registry.registryId), posture = HiveSkillsTargetPostureSchema.parse(reply.data);
|
|
77
|
+
if (!reply.success || posture.registryId !== registry.registryId || posture.target !== binding.agentTarget)
|
|
78
|
+
throw new Error('Native target binding differs');
|
|
79
|
+
return c.json({ ...base, ready: posture.ready, reason: posture.reason, configurationSha256: posture.configurationSha256, clientSha256: posture.clientSha256 });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return c.json({ ...base, ready: false, reason: 'Native target posture unavailable' });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
hiveskillsRouter.get('/jobs/:id', async (c) => { const registry = authorize(c.req.header('authorization')); const target = slug.safeParse(c.req.query('target')); if (!target.success)
|
|
86
|
+
throw new HttpError(400, 'VALIDATION_ERROR', 'Exact target required'); return c.json(await observed(registry, target.data, c.req.param('id'))); });
|
|
87
|
+
hiveskillsRouter.post('/jobs/:id', async (c) => {
|
|
88
|
+
const registry = authorize(c.req.header('authorization')), parsed = skillJobRequest.safeParse(await c.req.json().catch(() => null));
|
|
89
|
+
if (!parsed.success)
|
|
90
|
+
throw new HttpError(400, 'VALIDATION_ERROR', 'Exact approved Skills publication required');
|
|
91
|
+
const body = parsed.data, jobId = commandId(registry.registryId, c.req.param('id'));
|
|
92
|
+
if (createHash('sha256').update(JSON.stringify(body.manifest)).digest('hex') !== body.publicationSha256)
|
|
93
|
+
throw new HttpError(409, 'PUBLICATION_CONFLICT', 'Publication digest differs');
|
|
94
|
+
const { binding, node, client } = await targetClient(registry, body.target);
|
|
95
|
+
const request = { target: binding.agentTarget, registryId: registry.registryId, publicationSha256: body.publicationSha256, manifest: body.manifest };
|
|
96
|
+
const prior = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
|
|
97
|
+
if (prior) {
|
|
98
|
+
const identity = httpIdentity(jobId, 'POST', '/hiveskills/sync', JSON.stringify(request), 120);
|
|
99
|
+
if (prior.nodeId !== node.id || prior.createdBy !== 'service:hiveskills:' + registry.registryId || prior.command !== identity.command || prior.args !== JSON.stringify(identity.args))
|
|
100
|
+
throw new HttpError(409, 'JOB_BINDING_CONFLICT', 'Existing job has another target or publication');
|
|
101
|
+
return c.json(await observed(registry, body.target, c.req.param('id')));
|
|
102
|
+
}
|
|
103
|
+
await logAuditEvent({ action: 'skill.push', actorId: 'service:hiveskills:' + registry.registryId, targetType: 'node', targetId: node.id, details: { jobId, target: body.target, publicationSha256: body.publicationSha256, approvedBy: body.approvedBy, outcome: 'requested' } });
|
|
104
|
+
try {
|
|
105
|
+
const result = await client.syncHiveSkills(request, jobId);
|
|
106
|
+
await logAuditEvent({ action: 'skill.push', actorId: 'service:hiveskills:' + registry.registryId, targetType: 'node', targetId: node.id, details: { jobId, target: body.target, publicationSha256: body.publicationSha256, approvedBy: body.approvedBy, commandSha256: result.evidence.commandSha256 } });
|
|
107
|
+
return c.json({ jobId, status: result.response.success ? 'completed' : 'failed', result: result.response.success ? 'Exact approved publication synchronized' : 'Node completed with a bounded failure' });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
const row = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
|
|
111
|
+
if (!row)
|
|
112
|
+
return c.json({ jobId, status: 'not_admitted', result: 'No operation was admitted; resolve target readiness and prepare a new preview' });
|
|
113
|
+
return c.json(await observed(registry, body.target, c.req.param('id')));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
//# sourceMappingURL=hiveskills.js.map
|
package/dist/routes/library.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clientErrorMessage } from '../utils/safe-errors.js';
|
|
1
2
|
import { Hono } from 'hono';
|
|
2
3
|
import { getDb } from '../db/index.js';
|
|
3
4
|
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
@@ -373,7 +374,7 @@ libraryRouter.post('/deploy', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
373
374
|
nodeId: node.id,
|
|
374
375
|
nodeName: node.name,
|
|
375
376
|
status: 'failed',
|
|
376
|
-
error: err
|
|
377
|
+
error: clientErrorMessage(err, 'Unknown error'),
|
|
377
378
|
};
|
|
378
379
|
}
|
|
379
380
|
}));
|
|
@@ -383,7 +384,7 @@ libraryRouter.post('/deploy', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
383
384
|
nodeId: 'unknown',
|
|
384
385
|
nodeName: 'unknown',
|
|
385
386
|
status: 'failed',
|
|
386
|
-
error: r.reason
|
|
387
|
+
error: clientErrorMessage(r.reason, 'Unknown error'),
|
|
387
388
|
});
|
|
388
389
|
await logAuditEvent({
|
|
389
390
|
action: 'library.deploy',
|
package/dist/routes/logs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { streamSSE } from 'hono/streaming';
|
|
4
|
+
import { watchOathweaveStream } from '../middleware/oathweave.js';
|
|
4
5
|
import { getDb } from '../db/index.js';
|
|
5
6
|
import { authMiddleware } from '../middleware/auth.js';
|
|
6
7
|
import { sanitizeDetails } from '../services/audit-service.js';
|
|
@@ -157,9 +158,13 @@ logsRouter.get('/stream', (c) => {
|
|
|
157
158
|
id: String(eventId++),
|
|
158
159
|
});
|
|
159
160
|
});
|
|
161
|
+
const stopWatching = watchOathweaveStream(c.req.header('Authorization'), () => {
|
|
162
|
+
void stream.close();
|
|
163
|
+
});
|
|
160
164
|
// Wait until the client disconnects
|
|
161
165
|
stream.onAbort(() => {
|
|
162
166
|
clearInterval(heartbeatTimer);
|
|
167
|
+
stopWatching();
|
|
163
168
|
unsubscribe();
|
|
164
169
|
});
|
|
165
170
|
// Keep the stream open — the onAbort callback will clean up
|
package/dist/routes/metrics.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getDb } from '../db/index.js';
|
|
|
3
3
|
import { getRedis } from '../queue/redis.js';
|
|
4
4
|
import { getHeartbeatStreamStats } from '../services/heartbeat-stream.js';
|
|
5
5
|
import { safeEqual } from '../utils/safe-equal.js';
|
|
6
|
+
import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
|
|
6
7
|
export const metricsRouter = new Hono();
|
|
7
8
|
// ── In-process counters ──
|
|
8
9
|
const counters = {
|
|
@@ -115,10 +116,10 @@ metricsRouter.get('/', async (c) => {
|
|
|
115
116
|
try {
|
|
116
117
|
const db = getDb();
|
|
117
118
|
const [nodeCount, onlineNodes, pendingCommands, staleNodes, versions, oldestQueued] = await Promise.all([
|
|
118
|
-
db.node.count(),
|
|
119
|
-
db.node.count({ where: { status: 'ONLINE' } }),
|
|
119
|
+
db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS } } }),
|
|
120
|
+
db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, status: 'ONLINE' } }),
|
|
120
121
|
db.remoteCommand.count({ where: { status: { in: ['PENDING', 'QUEUED'] } } }),
|
|
121
|
-
db.node.count({ where: { lastHeartbeatAt: { lt: new Date(Date.now() - 120_000) } } }),
|
|
122
|
+
db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, lastHeartbeatAt: { lt: new Date(Date.now() - 120_000) } } }),
|
|
122
123
|
db.agent.groupBy({ by: ['version'], _count: true }),
|
|
123
124
|
db.remoteCommand.findFirst({ where: { status: { in: ['PENDING', 'QUEUED'] } }, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }),
|
|
124
125
|
]);
|