@hivemagnus/magnus-server 1.1.4 → 1.1.7
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/sdk/node-api-client.js +22 -3
- package/dist/security/managed-http.js +16 -2
- package/dist/services/agent-upgrade-service.js +1 -1
- 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 +15 -8
- package/dist/services/update-check-service.js +1 -1
- package/package.json +3 -3
package/agent-version.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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';
|
|
@@ -232,6 +232,25 @@ export class NodeApiClient {
|
|
|
232
232
|
async mutate(method, path, body, timeout = this.defaultTimeout) {
|
|
233
233
|
return (await this.mutateWithEvidence(method, path, body, timeout)).response;
|
|
234
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* A body holding a credential never goes through the digest in mutateWithEvidence.
|
|
237
|
+
* Enrolled nodes refuse it exactly as they refuse any mutation without a reviewed
|
|
238
|
+
* completion boundary; other nodes receive the same direct POST as before.
|
|
239
|
+
*/
|
|
240
|
+
async mutateWithCredential(path, body, timeout = this.defaultTimeout) {
|
|
241
|
+
if (!this.resourceContext)
|
|
242
|
+
throw new Error('Managed mutation requires canonical resource node identity');
|
|
243
|
+
const nativePath = '/' + path;
|
|
244
|
+
if (nativeCompletionDescriptor('POST', nativePath))
|
|
245
|
+
throw new Error('A managed native path cannot carry an unbound credential mutation');
|
|
246
|
+
const identity = credentialMutationIdentity('native-' + randomUUID(), 'POST', nativePath, Math.min(3600, Math.ceil(timeout / 1000)));
|
|
247
|
+
const admission = await admitResourceCommand(this.resourceContext, identity);
|
|
248
|
+
if (admission) {
|
|
249
|
+
await failResourceCommand(admission);
|
|
250
|
+
throw new Error('This enrolled-node mutation lacks a reviewed completion boundary; not dispatched');
|
|
251
|
+
}
|
|
252
|
+
return this.api(path, { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, timeout, retry: 0 }).json();
|
|
253
|
+
}
|
|
235
254
|
async evidencedMutation(method, path, body, commandId) {
|
|
236
255
|
const result = await this.mutateWithEvidence(method, path, body, this.defaultTimeout, commandId);
|
|
237
256
|
if (!result.evidence)
|
|
@@ -558,10 +577,10 @@ export class NodeApiClient {
|
|
|
558
577
|
return this.api.get('users').json();
|
|
559
578
|
}
|
|
560
579
|
async provisionUser(payload) {
|
|
561
|
-
return this.
|
|
580
|
+
return this.mutateWithCredential('users/provision', payload);
|
|
562
581
|
}
|
|
563
582
|
async changeUserPassword(payload) {
|
|
564
|
-
return this.
|
|
583
|
+
return this.mutateWithCredential('users/change-password', payload);
|
|
565
584
|
}
|
|
566
585
|
}
|
|
567
586
|
//# sourceMappingURL=node-api-client.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
|
}
|
|
@@ -185,7 +185,7 @@ export async function resolveAgentRelease(options = {}) {
|
|
|
185
185
|
return { version, integrity: options.integrity };
|
|
186
186
|
}
|
|
187
187
|
const registry = upgradeSettings().updateRegistry;
|
|
188
|
-
const url = `${registry}
|
|
188
|
+
const url = `${registry}/@${encodeURIComponent(AGENT_PACKAGE.slice(1))}/${version}`;
|
|
189
189
|
let response;
|
|
190
190
|
try {
|
|
191
191
|
response = await (options.fetchImpl ?? fetch)(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(options.timeoutMs ?? 15_000) });
|
|
@@ -1,3 +1,58 @@
|
|
|
1
|
+
import { parseGatewayStatusText } from './hermes-gateway-status.js';
|
|
2
|
+
/** Python port of parseGatewayStatusText for SSH detection, pinned to it by a shared test table. */
|
|
3
|
+
export const GATEWAY_STATUS_PYTHON = String.raw `
|
|
4
|
+
def parse_gateway_status(text):
|
|
5
|
+
text = re.sub(r'\x1b\[[0-9;]*m', '', text or '').lower()
|
|
6
|
+
lines = re.split(r'\r?\n', text)
|
|
7
|
+
states = []
|
|
8
|
+
for line in lines:
|
|
9
|
+
match = re.match(r'\s*active:\s*([a-z-]+)(?:\s*\(([^)]*)\))?', line)
|
|
10
|
+
if not match:
|
|
11
|
+
continue
|
|
12
|
+
state, detail = match.group(1), match.group(2) or ''
|
|
13
|
+
if state == 'active':
|
|
14
|
+
states.append('inactive' if detail.startswith('exited') else 'running')
|
|
15
|
+
elif state == 'reloading':
|
|
16
|
+
states.append('running')
|
|
17
|
+
elif state in ('inactive', 'deactivating'):
|
|
18
|
+
states.append('inactive')
|
|
19
|
+
elif state == 'failed':
|
|
20
|
+
states.append('failed')
|
|
21
|
+
elif state == 'activating':
|
|
22
|
+
states.append('failed' if 'auto-restart' in detail else 'unknown')
|
|
23
|
+
else:
|
|
24
|
+
states.append('unknown')
|
|
25
|
+
if states:
|
|
26
|
+
for state in ('running', 'failed', 'inactive'):
|
|
27
|
+
if state in states:
|
|
28
|
+
return state
|
|
29
|
+
return 'unknown'
|
|
30
|
+
ignored = re.compile(
|
|
31
|
+
r'^\s*(?:[a-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2})'
|
|
32
|
+
r'|^\s*(?:loaded|docs|main pid|tasks|memory|cpu|cgroup|process|drop-in|triggeredby|invocation|status|ip|mem peak)\s*:'
|
|
33
|
+
r'|^\s*[●○×↻✗✓]?\s*\S+\.service\b'
|
|
34
|
+
r'|^\s*[│├└]'
|
|
35
|
+
r'|\b(?:linger|auto-?start|on boot|at boot)\b'
|
|
36
|
+
r'|failed to connect to bus')
|
|
37
|
+
healthy = re.compile(r'\b(?:no|0|zero)\s+(?:errors?|failures?|failed)\b|\b(?:last|previous|recent)\s+(?:errors?|failures?)\b.*$')
|
|
38
|
+
current = []
|
|
39
|
+
in_history = False
|
|
40
|
+
for line in lines:
|
|
41
|
+
if re.match(r'\s*recent\b.*:\s*$', line):
|
|
42
|
+
in_history = True
|
|
43
|
+
elif not line.strip():
|
|
44
|
+
in_history = False
|
|
45
|
+
if not in_history:
|
|
46
|
+
current.append(line)
|
|
47
|
+
summary = [healthy.sub(' ', line) for line in current if not ignored.search(line)]
|
|
48
|
+
if any(re.search(r"\b(?:not[\s_-]+(?:running|active|started|loaded|installed)|isn'?t\s+running|inactive|stopped|disabled|dead|exited|could not be found)\b", line) for line in summary):
|
|
49
|
+
return 'inactive'
|
|
50
|
+
if any(re.search(r'\b(?:failed|failure|crashed|fatal|errors?|errored|unhealthy|not[\s_-]+healthy)\b', line) for line in summary):
|
|
51
|
+
return 'failed'
|
|
52
|
+
if any(re.search(r'\b(?:running|active|healthy)\b', line) for line in summary):
|
|
53
|
+
return 'running'
|
|
54
|
+
return 'unknown'
|
|
55
|
+
`;
|
|
1
56
|
const START = '__MAGNUS_HERMES_JSON_START__';
|
|
2
57
|
const END = '__MAGNUS_HERMES_JSON_END__';
|
|
3
58
|
function asRecord(value) {
|
|
@@ -36,7 +91,7 @@ export function normalizeServiceStatus(value) {
|
|
|
36
91
|
return 'error';
|
|
37
92
|
if (['not_installed', 'missing', 'not-found'].includes(text))
|
|
38
93
|
return 'not_installed';
|
|
39
|
-
return
|
|
94
|
+
return parseGatewayStatusText(text);
|
|
40
95
|
}
|
|
41
96
|
export function normalizeInstallationStatus(value, installed) {
|
|
42
97
|
const text = String(value ?? '').toLowerCase();
|
|
@@ -182,7 +237,7 @@ opts = json.loads(base64.b64decode(os.environ['MAGNUS_HERMES_PAYLOAD']).decode('
|
|
|
182
237
|
hermes_home = os.path.expanduser(opts.get('hermesHome') or '~/.hermes')
|
|
183
238
|
requested_bin = opts.get('hermesBin') or 'hermes'
|
|
184
239
|
dashboard_port = int(opts.get('dashboardPort') or 9119)
|
|
185
|
-
|
|
240
|
+
${GATEWAY_STATUS_PYTHON}
|
|
186
241
|
|
|
187
242
|
def looks_like_hermes_home(path):
|
|
188
243
|
home = pathlib.Path(path)
|
|
@@ -360,10 +415,10 @@ if resolved_bin:
|
|
|
360
415
|
plugins.append({'slug': cells[0], 'pluginType': cells[0].split('/')[0] if '/' in cells[0] else 'general', 'version': cells[2] or None, 'status': status_value, 'healthState': 'unknown', 'metadata': {'source': cells[4] or 'hermes plugins list'}})
|
|
361
416
|
|
|
362
417
|
gateway_result = run([resolved_bin, 'gateway', 'status'])
|
|
363
|
-
|
|
418
|
+
gateway_state = parse_gateway_status(gateway_result['stdout'] + gateway_result['stderr'])
|
|
364
419
|
else:
|
|
365
420
|
gateway_result = {'code': 127, 'stdout': '', 'stderr': 'hermes binary not found'}
|
|
366
|
-
|
|
421
|
+
gateway_state = 'unknown'
|
|
367
422
|
|
|
368
423
|
# Profile discovery fallback: Hermes can be installed with a valid home even when
|
|
369
424
|
# the binary is not visible to a non-login SSH shell. Scan profile directories,
|
|
@@ -410,7 +465,8 @@ if installed and not profiles and pathlib.Path(hermes_home).exists():
|
|
|
410
465
|
# Check systemd and port hints for local dashboard status.
|
|
411
466
|
dash_user = run(['systemctl', '--user', 'is-active', 'hermes-dashboard.service'])
|
|
412
467
|
dash_system = run(['systemctl', 'is-active', 'hermes-dashboard.service'])
|
|
413
|
-
|
|
468
|
+
# is-active prints exactly one state per unit; compare whole lines because 'inactive' contains 'active'.
|
|
469
|
+
dash_states = {line.strip() for line in (dash_user['stdout'] + '\n' + dash_system['stdout']).lower().splitlines()}
|
|
414
470
|
|
|
415
471
|
# Read base .env for API server hints and configured provider key names. Do not return secret values.
|
|
416
472
|
env_path = pathlib.Path(hermes_home) / '.env'
|
|
@@ -611,8 +667,11 @@ if plugins_dir.exists():
|
|
|
611
667
|
status = 'not_installed'
|
|
612
668
|
if installed:
|
|
613
669
|
status = 'detected'
|
|
614
|
-
if
|
|
670
|
+
if gateway_state == 'running':
|
|
615
671
|
status = 'running'
|
|
672
|
+
elif not resolved_bin:
|
|
673
|
+
# A Hermes home without a working binary is leftover data, not a usable installation.
|
|
674
|
+
status = 'degraded'
|
|
616
675
|
elif resolved_bin and not pathlib.Path(hermes_home).exists():
|
|
617
676
|
status = 'needs_setup'
|
|
618
677
|
|
|
@@ -622,8 +681,8 @@ out = {
|
|
|
622
681
|
'version': version,
|
|
623
682
|
'hermesBin': resolved_bin or requested_bin,
|
|
624
683
|
'hermesHome': hermes_home,
|
|
625
|
-
'gatewayStatus':
|
|
626
|
-
'dashboardStatus': 'running' if 'active' in
|
|
684
|
+
'gatewayStatus': gateway_state if gateway_state != 'unknown' else ('error' if gateway_result.get('code') not in (0, None) and resolved_bin else 'unknown'),
|
|
685
|
+
'dashboardStatus': 'running' if 'active' in dash_states else ('failed' if 'failed' in dash_states else ('inactive' if 'inactive' in dash_states else 'unknown')),
|
|
627
686
|
'dashboardPort': dashboard_port,
|
|
628
687
|
'apiServerEnabled': api_enabled,
|
|
629
688
|
'apiServerHost': api_host,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const ANSI_COLOUR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
|
2
|
+
const ACTIVE_LINE = /^\s*active:\s*([a-z-]+)(?:\s*\(([^)]*)\))?/;
|
|
3
|
+
const IGNORED_LINE = new RegExp([
|
|
4
|
+
// Journal tail: "Nov 02 10:00:00 host ..." or "2026-11-02 10:00:00 ...".
|
|
5
|
+
String.raw `^\s*(?:[a-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2})`,
|
|
6
|
+
// systemctl status fields other than Active, its unit title and its process tree.
|
|
7
|
+
String.raw `^\s*(?:loaded|docs|main pid|tasks|memory|cpu|cgroup|process|drop-in|triggeredby|invocation|status|ip|mem peak)\s*:`,
|
|
8
|
+
String.raw `^\s*[●○×↻✗✓]?\s*\S+\.service\b`,
|
|
9
|
+
String.raw `^\s*[│├└]`,
|
|
10
|
+
// Boot and session settings say nothing about the gateway's current state.
|
|
11
|
+
String.raw `\b(?:linger|auto-?start|on boot|at boot)\b`,
|
|
12
|
+
// systemctl could not ask: that is not evidence about the gateway either.
|
|
13
|
+
String.raw `failed to connect to bus`,
|
|
14
|
+
].join('|'));
|
|
15
|
+
// `hermes gateway status` ends with history under a heading such as "Recent gateway health:".
|
|
16
|
+
const HISTORY_HEADING = /^\s*recent\b.*:\s*$/;
|
|
17
|
+
const HEALTHY_PHRASE = /\b(?:no|0|zero)\s+(?:errors?|failures?|failed)\b|\b(?:last|previous|recent)\s+(?:errors?|failures?)\b.*$/g;
|
|
18
|
+
const NEGATIVE = /\b(?:not[\s_-]+(?:running|active|started|loaded|installed)|isn'?t\s+running|inactive|stopped|disabled|dead|exited|could not be found)\b/;
|
|
19
|
+
const FAILED = /\b(?:failed|failure|crashed|fatal|errors?|errored|unhealthy|not[\s_-]+healthy)\b/;
|
|
20
|
+
const POSITIVE = /\b(?:running|active|healthy)\b/;
|
|
21
|
+
function withoutHistory(lines) {
|
|
22
|
+
let inHistory = false;
|
|
23
|
+
return lines.filter((line) => {
|
|
24
|
+
if (HISTORY_HEADING.test(line))
|
|
25
|
+
inHistory = true;
|
|
26
|
+
else if (!line.trim())
|
|
27
|
+
inHistory = false;
|
|
28
|
+
return !inHistory;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function activeLineState(state, detail) {
|
|
32
|
+
if (state === 'active')
|
|
33
|
+
return detail.startsWith('exited') ? 'inactive' : 'running';
|
|
34
|
+
if (state === 'reloading')
|
|
35
|
+
return 'running';
|
|
36
|
+
if (state === 'inactive' || state === 'deactivating')
|
|
37
|
+
return 'inactive';
|
|
38
|
+
if (state === 'failed')
|
|
39
|
+
return 'failed';
|
|
40
|
+
// A unit waiting to restart after a crash is failing, one starting up is not known yet.
|
|
41
|
+
if (state === 'activating')
|
|
42
|
+
return detail.includes('auto-restart') ? 'failed' : 'unknown';
|
|
43
|
+
return 'unknown';
|
|
44
|
+
}
|
|
45
|
+
export function parseGatewayStatusText(text) {
|
|
46
|
+
const lines = text.replace(ANSI_COLOUR, '').toLowerCase().split(/\r?\n/);
|
|
47
|
+
const activeStates = lines.flatMap((line) => {
|
|
48
|
+
const match = ACTIVE_LINE.exec(line);
|
|
49
|
+
return match ? [activeLineState(match[1] ?? '', match[2] ?? '')] : [];
|
|
50
|
+
});
|
|
51
|
+
if (activeStates.length > 0) {
|
|
52
|
+
// User and system units can both print a status block.
|
|
53
|
+
for (const state of ['running', 'failed', 'inactive'])
|
|
54
|
+
if (activeStates.includes(state))
|
|
55
|
+
return state;
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
const summary = withoutHistory(lines).filter((line) => !IGNORED_LINE.test(line)).map((line) => line.replace(HEALTHY_PHRASE, ' '));
|
|
59
|
+
if (summary.some((line) => NEGATIVE.test(line)))
|
|
60
|
+
return 'inactive';
|
|
61
|
+
if (summary.some((line) => FAILED.test(line)))
|
|
62
|
+
return 'failed';
|
|
63
|
+
if (summary.some((line) => POSITIVE.test(line)))
|
|
64
|
+
return 'running';
|
|
65
|
+
return 'unknown';
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=hermes-gateway-status.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { observedDashboardEndpoint } from './hermes-dashboard-endpoint.js';
|
|
2
2
|
import { getDb } from '../db/index.js';
|
|
3
|
+
import { normalizeServiceStatus } from './hermes-cli-parser.js';
|
|
3
4
|
// SSH-backed Hermes mutations (deploy, configure, profile, gateway, dashboard,
|
|
4
5
|
// API-server, and OpenClaw operations) are retired; their routes return 410.
|
|
5
6
|
// This module keeps inventory reads and agent-reported state recording.
|
|
@@ -181,7 +182,7 @@ function profileFromNodeRecord(record, hermesHome) {
|
|
|
181
182
|
profileHome: stringField(record, 'profileHome', 'profile_home')
|
|
182
183
|
?? hermesHome.replace(/\/+$/, '') + '/profiles/' + name,
|
|
183
184
|
isDefault: booleanField(record, name === 'default', 'isDefault', 'is_default', 'default'),
|
|
184
|
-
gatewayStatus: (stringField(record, 'gatewayStatus', 'gateway_status')
|
|
185
|
+
gatewayStatus: normalizeServiceStatus(stringField(record, 'gatewayStatus', 'gateway_status')),
|
|
185
186
|
apiServerEnabled: booleanField(record, false, 'apiServerEnabled', 'api_server_enabled'),
|
|
186
187
|
apiServerHost: stringField(record, 'apiServerHost', 'api_server_host') ?? '127.0.0.1',
|
|
187
188
|
apiServerPort: numberField(record, 'apiServerPort', 'api_server_port'),
|
|
@@ -471,6 +472,10 @@ async function upsertInstallationFromDetection(params) {
|
|
|
471
472
|
});
|
|
472
473
|
profileIdsByName.set(profile.name, profileRow.id);
|
|
473
474
|
}
|
|
475
|
+
// Hermes is gone from this home: its remembered profiles must not keep counting as running gateways.
|
|
476
|
+
if (detection.status === 'not_installed') {
|
|
477
|
+
await db.hermesProfile.updateMany({ where: { installationId: installation.id }, data: { gatewayStatus: 'not_installed' } });
|
|
478
|
+
}
|
|
474
479
|
if (detection.profiles.length > 0) {
|
|
475
480
|
await db.hermesProfile.deleteMany({
|
|
476
481
|
where: {
|
|
@@ -31,7 +31,7 @@ export async function getOrchestraOverview() {
|
|
|
31
31
|
const db = getDb();
|
|
32
32
|
const [installations, profiles, jobs, recentJobs, skillAssignments, toolsetPolicies, roles, providerConfigs, credentialPools, mcpServers, messagingBindings, cronJobs, plugins, hooks,] = await Promise.all([
|
|
33
33
|
db.hermesInstallation.findMany({ select: { status: true } }),
|
|
34
|
-
db.hermesProfile.count(),
|
|
34
|
+
db.hermesProfile.count({ where: { installation: { status: { not: 'not_installed' } } } }),
|
|
35
35
|
db.hermesOperationJob.findMany({ select: { status: true } }),
|
|
36
36
|
db.hermesOperationJob.findMany({
|
|
37
37
|
orderBy: { startedAt: 'desc' },
|
|
@@ -53,7 +53,8 @@ export async function getOrchestraOverview() {
|
|
|
53
53
|
const jobCount = (s) => jobs.filter((j) => j.status === s).length;
|
|
54
54
|
return {
|
|
55
55
|
installations: {
|
|
56
|
-
|
|
56
|
+
// A row kept for a host that no longer has Hermes is not an installation.
|
|
57
|
+
total: installations.filter((i) => i.status !== 'not_installed').length,
|
|
57
58
|
running: installCount('running'),
|
|
58
59
|
degraded: installCount('degraded'),
|
|
59
60
|
error: installCount('error'),
|
|
@@ -138,7 +139,7 @@ export async function getOrchestraCapabilityInventory() {
|
|
|
138
139
|
capabilities.push({
|
|
139
140
|
key: `profile:${profile.id}:gateway`,
|
|
140
141
|
label: `${profileLabel} gateway`,
|
|
141
|
-
state: profile.gatewayStatus === 'running' ? 'supported' : ['stopped', 'failed', 'error'].includes(profile.gatewayStatus) ? 'missing' : 'unknown',
|
|
142
|
+
state: profile.gatewayStatus === 'running' ? 'supported' : ['inactive', 'stopped', 'failed', 'error', 'not_installed'].includes(profile.gatewayStatus) ? 'missing' : 'unknown',
|
|
142
143
|
detail: `gateway ${profile.gatewayStatus}`,
|
|
143
144
|
installationId: profile.installationId,
|
|
144
145
|
profileId: profile.id,
|
|
@@ -163,10 +163,12 @@ export const PARTICIPANT_RECEIPT_LIMITS = { default: 16_384, scheduler: 256 * 10
|
|
|
163
163
|
export class ParticipantEvidenceError extends Error {
|
|
164
164
|
code;
|
|
165
165
|
agentCode;
|
|
166
|
-
|
|
166
|
+
agentReason;
|
|
167
|
+
constructor(code, agentCode = null, agentReason = null) {
|
|
167
168
|
super(code);
|
|
168
169
|
this.code = code;
|
|
169
170
|
this.agentCode = agentCode;
|
|
171
|
+
this.agentReason = agentReason;
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
const failures = new Map();
|
|
@@ -177,20 +179,24 @@ export function lastParticipantFailures(nodeId) {
|
|
|
177
179
|
/** Bounded response consumption, including dishonest or absent content-length. */
|
|
178
180
|
export async function readParticipantReceipt(response, limit = PARTICIPANT_RECEIPT_LIMITS.default) {
|
|
179
181
|
if (response.status !== 200 || !response.body) {
|
|
180
|
-
// An optional machine-readable code from the participant
|
|
182
|
+
// An optional machine-readable code and fixed reason from the participant
|
|
183
|
+
// (`{ error: { code, reason } }`) are kept; nothing else is.
|
|
181
184
|
let agentCode = null;
|
|
185
|
+
let agentReason = null;
|
|
182
186
|
if (response.body && response.status >= 400) {
|
|
183
187
|
const text = await response.text().catch(() => '');
|
|
184
188
|
try {
|
|
185
|
-
const
|
|
186
|
-
if (typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(code))
|
|
187
|
-
agentCode = code;
|
|
189
|
+
const error = JSON.parse(text.slice(0, 4096))?.error;
|
|
190
|
+
if (typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code))
|
|
191
|
+
agentCode = error.code;
|
|
192
|
+
if (typeof error?.reason === 'string' && /^[a-z][a-z0-9-]{0,47}$/.test(error.reason))
|
|
193
|
+
agentReason = error.reason;
|
|
188
194
|
}
|
|
189
195
|
catch { /* no code */ }
|
|
190
196
|
}
|
|
191
197
|
else
|
|
192
198
|
await response.body?.cancel().catch(() => undefined);
|
|
193
|
-
throw new ParticipantEvidenceError(`participant-http-${response.status}`, agentCode);
|
|
199
|
+
throw new ParticipantEvidenceError(`participant-http-${response.status}`, agentCode, agentReason);
|
|
194
200
|
}
|
|
195
201
|
const reader = response.body.getReader();
|
|
196
202
|
const chunks = [];
|
|
@@ -280,8 +286,9 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
|
|
|
280
286
|
// Outstanding challenge remains pending.
|
|
281
287
|
const cause = error instanceof ParticipantEvidenceError ? error.code : 'participant-evidence-unavailable';
|
|
282
288
|
const agentCode = error instanceof ParticipantEvidenceError ? error.agentCode : null;
|
|
283
|
-
|
|
284
|
-
|
|
289
|
+
const agentReason = error instanceof ParticipantEvidenceError ? error.agentReason : null;
|
|
290
|
+
failures.set(`${nodeId}\n${participant}`, { participant, cause, agentCode, agentReason, at: new Date().toISOString() });
|
|
291
|
+
return { participant, status: 'pending', reason: 'participant-evidence-unavailable', cause, agentCode, agentReason };
|
|
285
292
|
}
|
|
286
293
|
failures.delete(`${nodeId}\n${participant}`);
|
|
287
294
|
return getDb().$transaction(async (tx) => {
|
|
@@ -53,7 +53,7 @@ export async function checkForUpdate(options = {}) {
|
|
|
53
53
|
const current = options.current ?? CONTROL_API_VERSION;
|
|
54
54
|
if (!settings.updateCheck)
|
|
55
55
|
return { ...emptyResult(false), current };
|
|
56
|
-
const url = `${settings.updateRegistry}
|
|
56
|
+
const url = `${settings.updateRegistry}/@${encodeURIComponent(META_PACKAGE.slice(1))}`;
|
|
57
57
|
const checkedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
58
58
|
try {
|
|
59
59
|
const response = await (options.fetchImpl ?? fetch)(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15_000) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/magnus-server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
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.7",
|
|
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.7",
|
|
106
106
|
"prisma": "^7.10.1"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|