@hivemagnus/magnus-agent 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/README.md +4 -0
- package/dist/executors/enrollment-drain.d.ts +2 -0
- package/dist/executors/enrollment-drain.js +24 -6
- package/dist/executors/hermes-gateway-status.d.ts +14 -0
- package/dist/executors/hermes-gateway-status.js +67 -0
- package/dist/executors/hermes-manager.d.ts +6 -0
- package/dist/executors/hermes-manager.js +34 -15
- package/dist/executors/scheduler-discovery.d.ts +24 -0
- package/dist/executors/scheduler-discovery.js +85 -5
- package/dist/executors/scheduler-drain.d.ts +9 -0
- package/dist/executors/scheduler-drain.js +44 -6
- package/dist/executors/scheduler-inventory.d.ts +10 -0
- package/dist/executors/scheduler-inventory.js +57 -9
- package/dist/hermes-sync.js +3 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ integrations is optional and stays inert until you configure it.
|
|
|
18
18
|
- Command execution with timeouts, signed results and a durable command ledger.
|
|
19
19
|
- Cron management, including finite managed schedules (see
|
|
20
20
|
[MANAGED_SCHEDULER.md](https://github.com/ChefTaco/magnus/blob/main/packages/agent/MANAGED_SCHEDULER.md)).
|
|
21
|
+
Nodes upgraded from before 1.1.6 may keep one stale remembered Hermes schedule
|
|
22
|
+
source per past Hermes run; see
|
|
23
|
+
[Hermes schedule sources remembered before 1.1.6](https://github.com/ChefTaco/magnus/blob/main/packages/agent/MANAGED_SCHEDULER.md#hermes-schedule-sources-remembered-before-116)
|
|
24
|
+
for the cleanup.
|
|
21
25
|
- File read and write with symlink-resolved path checks. Agent state and
|
|
22
26
|
secrets can never be read or written through the API.
|
|
23
27
|
- Skill publication, listing and removal, for example from
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
+
/** Fixed, non-secret reasons an agent drain proof was refused. */
|
|
3
|
+
export type DrainFailureReason = 'authority-unavailable' | 'authority-binding' | 'fence-failed' | 'supervisor-required' | 'supervisor-unverified' | 'inventory-failed' | 'challenge-expired' | 'receipt-failed';
|
|
2
4
|
export declare const HOST_LOCAL_WRITER_DOMAIN = "magnus.host-local-writer.v1\n";
|
|
3
5
|
export declare function canonicalDrainJson(value: unknown): string;
|
|
4
6
|
export declare const enrollmentDrainRoutes: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
@@ -6,6 +6,14 @@ import { CONTROL_API_URL, HOST_FQDN, NODE_MANAGER_SECRET, RESULT_SIGNING_SECRET,
|
|
|
6
6
|
import { controlApiFetch } from '../tls.js';
|
|
7
7
|
import { commandLedger, executionSupervisor } from './command-executor.js';
|
|
8
8
|
import { mutationBarrier } from './mutation-barrier.js';
|
|
9
|
+
import { logger } from '../utils/logger.js';
|
|
10
|
+
class DrainFailure extends Error {
|
|
11
|
+
reason;
|
|
12
|
+
constructor(reason, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.reason = reason;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
9
17
|
const descriptorSchema = z.object({ version: z.literal(1), participant: z.literal('agent'),
|
|
10
18
|
nodeId: z.string().min(1).max(128), nodeFqdn: z.string().min(1).max(253), epoch: z.number().int().positive(),
|
|
11
19
|
barrierId: z.string().guid(), challengeId: z.string().guid(), expiresAt: z.number().int().positive() }).strict();
|
|
@@ -72,6 +80,9 @@ enrollmentDrainRoutes.post('/enrollment-drain', async (c) => {
|
|
|
72
80
|
|| descriptor.expiresAt > Date.now() + 300_000 || RESULT_SIGNING_SECRET.length < 32
|
|
73
81
|
|| RESULT_SIGNING_SECRET === NODE_MANAGER_SECRET)
|
|
74
82
|
return c.json({ success: false, error: { code: 'INVALID_DRAIN_CHALLENGE' } }, 409);
|
|
83
|
+
// The step in progress names the refusal when a dependency throws.
|
|
84
|
+
let step = 'authority-unavailable';
|
|
85
|
+
const started = Date.now();
|
|
75
86
|
try {
|
|
76
87
|
const request = { nodeFqdn: HOST_FQDN, nonce: randomUUID() };
|
|
77
88
|
const response = await controlApiFetch(CONTROL_API_URL.replace(/\/+$/, '') + '/api/v1/resource-leases/enrollment/node-status', {
|
|
@@ -87,18 +98,21 @@ enrollmentDrainRoutes.post('/enrollment-drain', async (c) => {
|
|
|
87
98
|
|| status.nodeId !== descriptor.nodeId || status.state !== 'draining' || status.epoch !== descriptor.epoch
|
|
88
99
|
|| status.barrierId !== descriptor.barrierId || status.agentChallenge?.challengeId !== descriptor.challengeId
|
|
89
100
|
|| status.agentChallenge.expiresAt !== descriptor.expiresAt)
|
|
90
|
-
throw new
|
|
101
|
+
throw new DrainFailure('authority-binding', 'Drain authority binding differs');
|
|
91
102
|
const identity = { nodeId: descriptor.nodeId, nodeFqdn: descriptor.nodeFqdn, epoch: descriptor.epoch, barrierId: descriptor.barrierId };
|
|
103
|
+
step = 'fence-failed';
|
|
92
104
|
await mutationBarrier.drain(identity);
|
|
93
105
|
if (!MAGNUS_EXECUTION_SUPERVISOR_ENABLED)
|
|
94
|
-
throw new
|
|
106
|
+
throw new DrainFailure('supervisor-required', 'Supervised execution is required for enrollment proof');
|
|
107
|
+
step = 'inventory-failed';
|
|
95
108
|
const supervisor = await executionSupervisor.inventory();
|
|
96
109
|
if (!supervisor.supported || !supervisor.quiescent)
|
|
97
|
-
throw new
|
|
110
|
+
throw new DrainFailure('supervisor-unverified', 'Supervisor work remains unverified');
|
|
98
111
|
const mutations = await mutationBarrier.snapshot(identity);
|
|
99
112
|
const commands = await commandLedger.drainSnapshot();
|
|
100
113
|
if (descriptor.expiresAt <= Date.now())
|
|
101
|
-
throw new
|
|
114
|
+
throw new DrainFailure('challenge-expired', 'Drain challenge expired while collecting evidence');
|
|
115
|
+
step = 'receipt-failed';
|
|
102
116
|
const activeCount = mutations.activeCount + commands.activeCount;
|
|
103
117
|
const unknownCount = mutations.unknownCount + commands.unknownCount;
|
|
104
118
|
const receipt = { ...descriptor, status: activeCount || unknownCount ? 'pending' : 'drained', activeCount, unknownCount,
|
|
@@ -107,8 +121,12 @@ enrollmentDrainRoutes.post('/enrollment-drain', async (c) => {
|
|
|
107
121
|
c.header('Cache-Control', 'no-store');
|
|
108
122
|
return c.json({ ...receipt, signature });
|
|
109
123
|
}
|
|
110
|
-
catch {
|
|
111
|
-
|
|
124
|
+
catch (error) {
|
|
125
|
+
const reason = error instanceof DrainFailure ? error.reason : step;
|
|
126
|
+
// The local log keeps the real cause; the response carries only the fixed reason.
|
|
127
|
+
logger.warn('Agent enrollment drain refused', { reason, error: error instanceof Error ? error.message : String(error),
|
|
128
|
+
epoch: descriptor.epoch, barrierId: descriptor.barrierId, elapsedMs: Date.now() - started });
|
|
129
|
+
return c.json({ success: false, error: { code: 'DRAIN_EVIDENCE_UNAVAILABLE', reason,
|
|
112
130
|
message: 'Drain evidence is incomplete; no enrollment acknowledgment issued' } }, 409);
|
|
113
131
|
}
|
|
114
132
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies `hermes gateway status` and systemd-style output. Mirrored
|
|
3
|
+
* byte-for-byte by the server's copy, which also carries the Python port used
|
|
4
|
+
* by SSH detection; a server test pins all three to one table.
|
|
5
|
+
*
|
|
6
|
+
* `systemctl status` output appends a journal tail, so a running service can
|
|
7
|
+
* print "Stopped ..." or "error" from an earlier run. A systemd `Active:` line
|
|
8
|
+
* is therefore authoritative, and Hermes' own "Recent gateway health" history is
|
|
9
|
+
* skipped. Without an `Active:` line, only summary lines count, and on
|
|
10
|
+
* those a negative state outranks a failure, which outranks a positive one.
|
|
11
|
+
*/
|
|
12
|
+
export type HermesGatewayState = 'running' | 'inactive' | 'failed' | 'unknown';
|
|
13
|
+
export declare function parseGatewayStatusText(text: string): HermesGatewayState;
|
|
14
|
+
//# sourceMappingURL=hermes-gateway-status.d.ts.map
|
|
@@ -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
|
|
@@ -108,6 +108,12 @@ export declare function configureHermes(options: {
|
|
|
108
108
|
warnings: string[];
|
|
109
109
|
state: HermesNodeState;
|
|
110
110
|
}>;
|
|
111
|
+
export declare function hermesInstallationStatus(input: {
|
|
112
|
+
installed: boolean;
|
|
113
|
+
runnable: boolean;
|
|
114
|
+
gatewayStatus: HermesServiceStatus;
|
|
115
|
+
configPresent: boolean;
|
|
116
|
+
}): HermesNodeState['status'];
|
|
111
117
|
export declare function collectHermesState(options?: {
|
|
112
118
|
hermesHome?: string;
|
|
113
119
|
hermesBin?: string;
|
|
@@ -7,6 +7,7 @@ import { Hono } from 'hono';
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { observeHermesDashboard } from './hermes-dashboard-discovery.js';
|
|
9
9
|
import { inspectHiveMemToken } from './hivemem-token.js';
|
|
10
|
+
import { parseGatewayStatusText } from './hermes-gateway-status.js';
|
|
10
11
|
import { childEnvironment } from '../utils/child-env.js';
|
|
11
12
|
import { armChildTimeout } from '../utils/child-timeout.js';
|
|
12
13
|
const OFFICIAL_INSTALL_SCRIPT = 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh';
|
|
@@ -417,7 +418,7 @@ function parseProfileListText(text, home) {
|
|
|
417
418
|
name,
|
|
418
419
|
profileHome: join(home, 'profiles', name),
|
|
419
420
|
isDefault: nameCell.startsWith('*') || nameCell.startsWith('◆') || /\bdefault\b/i.test(nameCell),
|
|
420
|
-
gatewayStatus: cells[2]
|
|
421
|
+
gatewayStatus: cells[2] ? parseGatewayStatusText(cells[2]) : 'unknown',
|
|
421
422
|
modelSummary: cells[1] && !['-', '—'].includes(cells[1]) ? cells[1] : null,
|
|
422
423
|
distribution: cells[4] && !['-', '—'].includes(cells[4]) ? cells[4] : null,
|
|
423
424
|
metadata: { source: 'hermes profile list' },
|
|
@@ -425,6 +426,14 @@ function parseProfileListText(text, home) {
|
|
|
425
426
|
}
|
|
426
427
|
return profiles;
|
|
427
428
|
}
|
|
429
|
+
function profileGatewayStatus(profile, installationStatus) {
|
|
430
|
+
const reported = profileString(profile, 'gatewayStatus', 'gateway_status');
|
|
431
|
+
const state = reported ? parseGatewayStatusText(reported) : 'unknown';
|
|
432
|
+
if (state !== 'unknown')
|
|
433
|
+
return state;
|
|
434
|
+
return profile['isDefault'] === true || profile['is_default'] === true || profile['default'] === true || profile['name'] === 'default'
|
|
435
|
+
? installationStatus : 'unknown';
|
|
436
|
+
}
|
|
428
437
|
function profileString(profile, ...keys) {
|
|
429
438
|
for (const key of keys) {
|
|
430
439
|
const value = profile[key];
|
|
@@ -458,15 +467,17 @@ function profileNumber(profile, ...keys) {
|
|
|
458
467
|
return null;
|
|
459
468
|
}
|
|
460
469
|
/**
|
|
461
|
-
* Hermes exposes
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
470
|
+
* Hermes exposes API-server and model routing at the installation level.
|
|
471
|
+
* Profile-list output varies by Hermes release and often contains only names,
|
|
472
|
+
* so carry the observed installation posture into every profile unless that
|
|
473
|
+
* profile explicitly reported its own value. `hermes gateway status` observes
|
|
474
|
+
* only the default profile's gateway, so another profile that reported nothing
|
|
475
|
+
* stays unknown instead of counting as one more running gateway.
|
|
465
476
|
*/
|
|
466
477
|
function enrichProfiles(profiles, installation) {
|
|
467
478
|
return profiles.map((profile) => ({
|
|
468
479
|
...profile,
|
|
469
|
-
gatewayStatus:
|
|
480
|
+
gatewayStatus: profileGatewayStatus(profile, installation.gatewayStatus),
|
|
470
481
|
providerSummary: profileString(profile, 'providerSummary', 'provider_summary', 'provider') ?? installation.configuration.modelProvider,
|
|
471
482
|
modelSummary: profileString(profile, 'modelSummary', 'model_summary', 'model') ?? installation.configuration.modelName,
|
|
472
483
|
apiServerEnabled: profileBoolean(profile, installation.apiServerEnabled, 'apiServerEnabled', 'api_server_enabled'),
|
|
@@ -589,6 +600,15 @@ export async function configureHermes(options, configuration) {
|
|
|
589
600
|
return { warnings, state: await collectHermesState(options, true) };
|
|
590
601
|
}
|
|
591
602
|
let stateCache = null;
|
|
603
|
+
export function hermesInstallationStatus(input) {
|
|
604
|
+
if (!input.installed)
|
|
605
|
+
return 'not_installed';
|
|
606
|
+
if (input.gatewayStatus === 'running')
|
|
607
|
+
return 'running';
|
|
608
|
+
if (!input.runnable)
|
|
609
|
+
return 'degraded';
|
|
610
|
+
return input.configPresent ? 'detected' : 'needs_setup';
|
|
611
|
+
}
|
|
592
612
|
export async function collectHermesState(options = {}, refresh = false) {
|
|
593
613
|
const requested = { hermesHome: options.hermesHome ?? DEFAULT_HERMES_HOME, hermesBin: options.hermesBin ?? 'hermes' };
|
|
594
614
|
const runtime = await discoverRuntimeTarget(requested);
|
|
@@ -605,9 +625,11 @@ export async function collectHermesState(options = {}, refresh = false) {
|
|
|
605
625
|
const fallbackVersion = runtime.fallbackVersion;
|
|
606
626
|
const homeProfiles = await profilesFromHome(resolved.home);
|
|
607
627
|
const installed = versionResult.code === 0 || fallbackVersion?.code === 0 || runtime.homeDetected || configPresent || homeProfiles.length > 0;
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
const
|
|
628
|
+
// A Hermes home without a working binary is leftover data, not a usable installation.
|
|
629
|
+
const runnable = versionResult.code === 0 || fallbackVersion?.code === 0;
|
|
630
|
+
const version = runnable ? firstLine(versionResult.stdout || fallbackVersion?.stdout || versionResult.stderr) : null;
|
|
631
|
+
const gatewayResult = runnable ? await runHermes(runtime.requested, ['gateway', 'status']) : null;
|
|
632
|
+
const profileListResult = runnable ? await runHermes(runtime.requested, ['profile', 'list', '--json'], 15_000) : null;
|
|
611
633
|
let profiles = [];
|
|
612
634
|
if (profileListResult?.code === 0 && profileListResult.stdout.trim()) {
|
|
613
635
|
try {
|
|
@@ -617,7 +639,7 @@ export async function collectHermesState(options = {}, refresh = false) {
|
|
|
617
639
|
}
|
|
618
640
|
catch { /* fall through with empty profiles */ }
|
|
619
641
|
}
|
|
620
|
-
if (profiles.length === 0 &&
|
|
642
|
+
if (profiles.length === 0 && runnable) {
|
|
621
643
|
const textResult = await runHermes(runtime.requested, ['profile', 'list'], 15_000);
|
|
622
644
|
profiles = parseProfileListText(`${textResult.stdout}\n${textResult.stderr}`, resolved.home).slice(0, 50);
|
|
623
645
|
}
|
|
@@ -632,12 +654,9 @@ export async function collectHermesState(options = {}, refresh = false) {
|
|
|
632
654
|
metadata: { source: 'implicit default profile' },
|
|
633
655
|
}];
|
|
634
656
|
}
|
|
635
|
-
const gatewayText = `${gatewayResult?.stdout ?? ''}\n${gatewayResult?.stderr ?? ''}`.toLowerCase();
|
|
636
657
|
const gatewayStatus = !installed
|
|
637
658
|
? 'not_installed'
|
|
638
|
-
:
|
|
639
|
-
: /failed|error/.test(gatewayText) ? 'failed'
|
|
640
|
-
: /inactive|stopped|not running/.test(gatewayText) ? 'inactive' : 'unknown';
|
|
659
|
+
: parseGatewayStatusText(`${gatewayResult?.stdout ?? ''}\n${gatewayResult?.stderr ?? ''}`);
|
|
641
660
|
const modelProvider = yamlBlockValue(config, 'model', 'provider');
|
|
642
661
|
const modelName = yamlBlockValue(config, 'model', 'default');
|
|
643
662
|
const modelBaseUrl = yamlBlockValue(config, 'model', 'base_url');
|
|
@@ -703,7 +722,7 @@ export async function collectHermesState(options = {}, refresh = false) {
|
|
|
703
722
|
const dashboard = await observeHermesDashboard(resolved.home);
|
|
704
723
|
const state = {
|
|
705
724
|
installed,
|
|
706
|
-
status:
|
|
725
|
+
status: hermesInstallationStatus({ installed, runnable, gatewayStatus, configPresent }),
|
|
707
726
|
installMode: installed ? 'official-script' : 'existing',
|
|
708
727
|
version,
|
|
709
728
|
hermesBin: requested.hermesBin,
|
|
@@ -46,6 +46,30 @@ export declare function boundedTimerInlineSources(executable: string, argv: stri
|
|
|
46
46
|
commands: string[];
|
|
47
47
|
mdmonitor: boolean;
|
|
48
48
|
} | null;
|
|
49
|
+
export declare function stableServiceProperties(raw: string): string;
|
|
50
|
+
/** A timer list keeps unit, load and active state; the SUB column
|
|
51
|
+
* (waiting/running/elapsed) and description change whenever a timer fires. */
|
|
52
|
+
export declare function stableTimerUnitList(raw: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a program or script file name is Hermes: the `hermes` CLI and its
|
|
55
|
+
* `hermes-*`/`hermes_*` shims, or the gateway runner. Only the final path
|
|
56
|
+
* component counts; a `.hermes` or `hermes` directory is where unrelated
|
|
57
|
+
* software can live too, so it never identifies a runtime by itself.
|
|
58
|
+
*/
|
|
59
|
+
export declare function hermesProgramName(path: string): boolean;
|
|
60
|
+
/** Whether a Python module (`-m`) is Hermes: `hermes`, `hermes_cli`, `hermes_cli.main`, `hermes_agent`... */
|
|
61
|
+
export declare function hermesModuleName(module: string): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* A process is a possible Hermes runtime when what it executes is Hermes: the
|
|
64
|
+
* basename of its executable, the basename of the script or the module an
|
|
65
|
+
* interpreter runs, or an explicit `--hermes-home` option. Other argument text
|
|
66
|
+
* is data, not identity: an operator's `grep hermes`, a report filter naming a
|
|
67
|
+
* Hermes kind, a shell whose inline code mentions Hermes, or unrelated software
|
|
68
|
+
* installed under a `.hermes` directory is not a runtime (any Hermes it starts
|
|
69
|
+
* is a separate process observed on its own). Every command the dashboard
|
|
70
|
+
* recogniser accepts (hermes-dashboard-discovery.ts) is recognised here too.
|
|
71
|
+
*/
|
|
72
|
+
export declare function hermesRuntimeCommand(args: string[]): boolean;
|
|
49
73
|
export declare function discoverSchedulerSources(options?: DiscoveryOptions): Promise<{
|
|
50
74
|
homes: string[];
|
|
51
75
|
hermesRoots: string[];
|
|
@@ -113,6 +113,85 @@ export function boundedTimerInlineSources(executable, argv) {
|
|
|
113
113
|
commands: ['sponge', ...(shell.startsWith('/') ? [] : [shell])], mdmonitor: false };
|
|
114
114
|
return null;
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* systemd appends the status of the last run to every Exec* record
|
|
118
|
+
* (`start_time=[...] ; stop_time=[...] ; pid=... ; code=... ; status=...`).
|
|
119
|
+
* That is runtime state, not the command definition: a timer that fires
|
|
120
|
+
* between two observations would otherwise change the source evidence on
|
|
121
|
+
* every run. Remove only a trailing run of those exact fields after the
|
|
122
|
+
* record's final `ignore_errors=`; anything else is kept byte for byte.
|
|
123
|
+
*/
|
|
124
|
+
const EXEC_RECORD_WITH_RUNTIME = /^((?:ExecStart|ExecStartPre|ExecStartPost|ExecCondition)=\{[\s\S]*;\s*ignore_errors=(?:yes|no))((?:\s*;\s*(?:start_time=\[[^\]\n]*\]|stop_time=\[[^\]\n]*\]|pid=\d+|code=[^\s;}]+|status=[^\s;}]+))+)(\s*\})$/;
|
|
125
|
+
export function stableServiceProperties(raw) {
|
|
126
|
+
return raw.split('\n').map(line => {
|
|
127
|
+
const match = EXEC_RECORD_WITH_RUNTIME.exec(line);
|
|
128
|
+
return match ? match[1] + match[3] : line;
|
|
129
|
+
}).join('\n');
|
|
130
|
+
}
|
|
131
|
+
/** A timer list keeps unit, load and active state; the SUB column
|
|
132
|
+
* (waiting/running/elapsed) and description change whenever a timer fires. */
|
|
133
|
+
export function stableTimerUnitList(raw) {
|
|
134
|
+
return raw.split('\n').map(line => line.trim().split(/\s+/).slice(0, 3).join(' ')).join('\n');
|
|
135
|
+
}
|
|
136
|
+
const SCRIPT_INTERPRETER = /^(?:python(?:[23](?:\.\d+)?)?|pypy3?|node|bun|deno|bash|sh|dash|zsh)$/;
|
|
137
|
+
/** Interpreter options whose next argument is a value (a preloaded module, a
|
|
138
|
+
* warning filter), never the script being run. */
|
|
139
|
+
const INTERPRETER_VALUE_OPTIONS = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader', '-C',
|
|
140
|
+
'--conditions', '--env-file', '--input-type', '-W', '-X', '--check-hash-based-pycs', '-o']);
|
|
141
|
+
const basenameOf = (path) => path.slice(path.lastIndexOf('/') + 1);
|
|
142
|
+
/**
|
|
143
|
+
* Whether a program or script file name is Hermes: the `hermes` CLI and its
|
|
144
|
+
* `hermes-*`/`hermes_*` shims, or the gateway runner. Only the final path
|
|
145
|
+
* component counts; a `.hermes` or `hermes` directory is where unrelated
|
|
146
|
+
* software can live too, so it never identifies a runtime by itself.
|
|
147
|
+
*/
|
|
148
|
+
export function hermesProgramName(path) {
|
|
149
|
+
const name = basenameOf(path).replace(/\.(?:py|pyc|js|mjs|cjs|ts|sh|exe)$/i, '');
|
|
150
|
+
return /^hermes(?:[-_][A-Za-z0-9_.-]*)?$/i.test(name) || /^run_gateway$/i.test(name);
|
|
151
|
+
}
|
|
152
|
+
/** Whether a Python module (`-m`) is Hermes: `hermes`, `hermes_cli`, `hermes_cli.main`, `hermes_agent`... */
|
|
153
|
+
export function hermesModuleName(module) {
|
|
154
|
+
return /^hermes(?:_[A-Za-z0-9_]*)?(?:\.[A-Za-z0-9_.]*)?$/i.test(module) || /^run_gateway$/i.test(module);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A process is a possible Hermes runtime when what it executes is Hermes: the
|
|
158
|
+
* basename of its executable, the basename of the script or the module an
|
|
159
|
+
* interpreter runs, or an explicit `--hermes-home` option. Other argument text
|
|
160
|
+
* is data, not identity: an operator's `grep hermes`, a report filter naming a
|
|
161
|
+
* Hermes kind, a shell whose inline code mentions Hermes, or unrelated software
|
|
162
|
+
* installed under a `.hermes` directory is not a runtime (any Hermes it starts
|
|
163
|
+
* is a separate process observed on its own). Every command the dashboard
|
|
164
|
+
* recogniser accepts (hermes-dashboard-discovery.ts) is recognised here too.
|
|
165
|
+
*/
|
|
166
|
+
export function hermesRuntimeCommand(args) {
|
|
167
|
+
const executable = args[0] ?? '';
|
|
168
|
+
if (hermesProgramName(executable))
|
|
169
|
+
return true;
|
|
170
|
+
if (args.some(arg => /^--hermes-home(?:=|$)/.test(arg)))
|
|
171
|
+
return true;
|
|
172
|
+
if (!SCRIPT_INTERPRETER.test(basenameOf(executable)))
|
|
173
|
+
return false;
|
|
174
|
+
for (let index = 1; index < args.length; index++) {
|
|
175
|
+
const arg = args[index];
|
|
176
|
+
if (arg === '-m')
|
|
177
|
+
return hermesModuleName(args[index + 1] ?? '');
|
|
178
|
+
if (/^-m./.test(arg))
|
|
179
|
+
return hermesModuleName(arg.slice(2));
|
|
180
|
+
// Inline code is not a script identity.
|
|
181
|
+
if (/^-[a-zA-Z]*[ce]$/.test(arg) || ['--command', '--eval', '--print'].includes(arg))
|
|
182
|
+
return false;
|
|
183
|
+
if (arg === '--')
|
|
184
|
+
return hermesProgramName(args[index + 1] ?? '');
|
|
185
|
+
if (INTERPRETER_VALUE_OPTIONS.has(arg)) {
|
|
186
|
+
index++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (arg.startsWith('-'))
|
|
190
|
+
continue;
|
|
191
|
+
return hermesProgramName(arg);
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
116
195
|
export async function discoverSchedulerSources(options = {}) {
|
|
117
196
|
const issues = [];
|
|
118
197
|
const blockers = [];
|
|
@@ -207,7 +286,7 @@ export async function discoverSchedulerSources(options = {}) {
|
|
|
207
286
|
'--state=active,activating,reloading', '--all', '--plain', '--no-legend', '--no-pager']);
|
|
208
287
|
const enabledRaw = await run('/usr/bin/systemctl', [...scope, 'list-unit-files', '--type=timer',
|
|
209
288
|
'--state=enabled,enabled-runtime', '--plain', '--no-legend', '--no-pager']);
|
|
210
|
-
sources[sourceKey] = schedulerContentDigest(JSON.stringify([activeRaw, enabledRaw]));
|
|
289
|
+
sources[sourceKey] = schedulerContentDigest(JSON.stringify([stableTimerUnitList(activeRaw), enabledRaw]));
|
|
211
290
|
const names = [...new Set([...timerNames(activeRaw), ...timerNames(enabledRaw)])].sort(compareText);
|
|
212
291
|
for (const name of names) {
|
|
213
292
|
try {
|
|
@@ -416,10 +495,11 @@ export async function discoverSchedulerSources(options = {}) {
|
|
|
416
495
|
if (error.code !== 'ENOENT')
|
|
417
496
|
throw error;
|
|
418
497
|
}
|
|
498
|
+
const stableService = stableServiceProperties(serviceRaw);
|
|
419
499
|
if (await run('/usr/bin/systemctl', showArgs) !== raw
|
|
420
|
-
|| await run('/usr/bin/systemctl', serviceShowArgs) !==
|
|
500
|
+
|| stableServiceProperties(await run('/usr/bin/systemctl', serviceShowArgs)) !== stableService)
|
|
421
501
|
throw new Error('Timer or triggered service identity changed during observation');
|
|
422
|
-
sources[sourceKey + ':' + hash(name)] = schedulerContentDigest(JSON.stringify([raw,
|
|
502
|
+
sources[sourceKey + ':' + hash(name)] = schedulerContentDigest(JSON.stringify([raw, stableService]));
|
|
423
503
|
blockEvidence(kind, evidence, 1);
|
|
424
504
|
}
|
|
425
505
|
catch (error) {
|
|
@@ -628,13 +708,13 @@ export async function discoverSchedulerSources(options = {}) {
|
|
|
628
708
|
const command = await boundedSchedulerRead(join(proc, pid, 'cmdline'));
|
|
629
709
|
if (command.length > 256 * 1024)
|
|
630
710
|
throw new Error('Process command bound');
|
|
631
|
-
if (
|
|
711
|
+
if (hermesRuntimeCommand(command.toString('utf8').split('\0'))) {
|
|
632
712
|
try {
|
|
633
713
|
const startedAt = await processStart(pid);
|
|
634
714
|
const member = await boundedSchedulerMemberEvidence(join(proc, pid, 'cmdline'), `process:${pid}:start:${startedAt}`);
|
|
635
715
|
const verified = await boundedSchedulerRead(join(proc, pid, 'cmdline'));
|
|
636
716
|
if (await processStart(pid) !== startedAt || schedulerContentDigest(verified) !== member.contentSha256
|
|
637
|
-
||
|
|
717
|
+
|| !hermesRuntimeCommand(verified.toString('utf8').split('\0')))
|
|
638
718
|
throw new Error('Process identity changed during observation');
|
|
639
719
|
blockEvidence('NATIVE_HERMES_RUNTIME_UNINSTRUMENTED', [member]);
|
|
640
720
|
}
|
|
@@ -2,6 +2,15 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { freshSchedulerStatus } from './scheduler-status.js';
|
|
3
3
|
import { ManagedScheduler } from './managed-scheduler.js';
|
|
4
4
|
import { type SchedulerInventoryOptions } from './scheduler-inventory.js';
|
|
5
|
+
/** Fixed, non-secret reasons a scheduler drain proof was refused. */
|
|
6
|
+
export type SchedulerDrainFailureReason = 'authority-unavailable' | 'authority-binding' | 'fence-failed' | 'inventory-failed' | 'inventory-changed' | 'ownership-changed' | 'challenge-expired' | 'receipt-failed';
|
|
7
|
+
/** Which parts of two inventories differ: field names, source keys (path hashes or
|
|
8
|
+
* fixed labels) and blocker kinds only, never source content or paths. */
|
|
9
|
+
export declare function schedulerInventoryDifference(first: Record<string, unknown>, second: Record<string, unknown>): {
|
|
10
|
+
fields: string[];
|
|
11
|
+
sources: string[];
|
|
12
|
+
blockerKinds: (string | undefined)[];
|
|
13
|
+
};
|
|
5
14
|
export declare function canonicalSchedulerEvidence(value: unknown): string;
|
|
6
15
|
export declare function schedulerDrainRoutes(options?: {
|
|
7
16
|
scheduler?: ManagedScheduler;
|
|
@@ -8,6 +8,32 @@ import { nodeAuthMiddleware } from '../middleware.js';
|
|
|
8
8
|
import { ManagedScheduler } from './managed-scheduler.js';
|
|
9
9
|
import { schedulerInventory } from './scheduler-inventory.js';
|
|
10
10
|
import { schedulerAuthority } from './scheduler-authority.js';
|
|
11
|
+
import { logger } from '../utils/logger.js';
|
|
12
|
+
class SchedulerDrainFailure extends Error {
|
|
13
|
+
reason;
|
|
14
|
+
detail;
|
|
15
|
+
constructor(reason, message, detail = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.reason = reason;
|
|
18
|
+
this.detail = detail;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Which parts of two inventories differ: field names, source keys (path hashes or
|
|
22
|
+
* fixed labels) and blocker kinds only, never source content or paths. */
|
|
23
|
+
export function schedulerInventoryDifference(first, second) {
|
|
24
|
+
const fields = [...new Set([...Object.keys(first), ...Object.keys(second)])].sort()
|
|
25
|
+
.filter(key => canonicalSchedulerEvidence(first[key]) !== canonicalSchedulerEvidence(second[key]));
|
|
26
|
+
const sourcesOf = (value) => (value['sources'] ?? {});
|
|
27
|
+
const sources = [...new Set([...Object.keys(sourcesOf(first)), ...Object.keys(sourcesOf(second))])].sort()
|
|
28
|
+
.filter(key => sourcesOf(first)[key] !== sourcesOf(second)[key]);
|
|
29
|
+
const blockersOf = (value) => new Set((value['blockers'] ?? [])
|
|
30
|
+
.map(row => row.kind + ':' + row.sourceSha256 + ':' + row.count));
|
|
31
|
+
const left = blockersOf(first);
|
|
32
|
+
const right = blockersOf(second);
|
|
33
|
+
const blockerKinds = [...new Set([...left].filter(row => !right.has(row)).concat([...right].filter(row => !left.has(row)))
|
|
34
|
+
.map(row => row.split(':')[0]))].sort();
|
|
35
|
+
return { fields, sources: sources.slice(0, 32), blockerKinds };
|
|
36
|
+
}
|
|
11
37
|
const EXTERNAL_SCHEDULER_KINDS = new Set([
|
|
12
38
|
'LEGACY_OR_UNSUPPORTED_CRONTAB', 'SYSTEM_ANACRON', 'SYSTEM_CRONTAB', 'SYSTEM_CRON_SOURCE',
|
|
13
39
|
'SYSTEM_TIMER_DEFINITION', 'USER_TIMER_DEFINITION', 'USER_TIMER_OFFLINE_ENABLED',
|
|
@@ -33,18 +59,23 @@ export function schedulerDrainRoutes(options = {}) {
|
|
|
33
59
|
if (!parsed.success)
|
|
34
60
|
return c.json({ success: false, error: { code: 'INVALID_SCHEDULER_DRAIN' } }, 400);
|
|
35
61
|
const descriptor = parsed.data;
|
|
62
|
+
const started = Date.now();
|
|
36
63
|
if (descriptor.nodeFqdn !== HOST_FQDN || descriptor.expiresAt <= Date.now() || descriptor.expiresAt > Date.now() + 300_000
|
|
37
64
|
|| RESULT_SIGNING_SECRET.length < 32 || RESULT_SIGNING_SECRET === NODE_MANAGER_SECRET)
|
|
38
65
|
return c.json({ success: false, error: { code: 'INVALID_SCHEDULER_DRAIN' } }, 409);
|
|
66
|
+
// The step in progress names the refusal when a dependency throws.
|
|
67
|
+
let step = 'authority-unavailable';
|
|
39
68
|
try {
|
|
40
69
|
const query = { nodeFqdn: HOST_FQDN, nonce: randomUUID() };
|
|
41
70
|
const status = schedulerStatusSchema.parse(await (options.status ?? freshSchedulerStatus)(query));
|
|
42
71
|
if (status.nonce !== query.nonce || status.nodeFqdn !== descriptor.nodeFqdn || status.nodeId !== descriptor.nodeId
|
|
43
72
|
|| status.state !== 'draining' || status.epoch !== descriptor.epoch || status.barrierId !== descriptor.barrierId
|
|
44
73
|
|| status.schedulerChallenge?.challengeId !== descriptor.challengeId || status.schedulerChallenge.expiresAt !== descriptor.expiresAt)
|
|
45
|
-
throw new
|
|
74
|
+
throw new SchedulerDrainFailure('authority-binding', 'Scheduler drain authority binding differs');
|
|
46
75
|
const identity = { nodeId: descriptor.nodeId, nodeFqdn: descriptor.nodeFqdn, epoch: descriptor.epoch, barrierId: descriptor.barrierId };
|
|
76
|
+
step = 'fence-failed';
|
|
47
77
|
await scheduler.drain(identity);
|
|
78
|
+
step = 'inventory-failed';
|
|
48
79
|
const owned = await scheduler.inventory(identity);
|
|
49
80
|
const recovery = options.recover ?? (id => scheduler.recover(id, schedulerAuthority()));
|
|
50
81
|
// Recover only original receipts. Never call trigger/admit/run from a drain.
|
|
@@ -69,18 +100,19 @@ export function schedulerDrainRoutes(options = {}) {
|
|
|
69
100
|
if (firstBoundaryReady) {
|
|
70
101
|
observed = await schedulerInventory({ ...options.inventory, scheduler, expectedBarrier: identity });
|
|
71
102
|
if (canonicalSchedulerEvidence(first) !== canonicalSchedulerEvidence(observed))
|
|
72
|
-
throw new
|
|
103
|
+
throw new SchedulerDrainFailure('inventory-changed', 'Scheduler source inventory changed during proof', schedulerInventoryDifference(first, observed));
|
|
73
104
|
}
|
|
74
105
|
// Bind the final managed snapshot under its flock, after all source reads.
|
|
75
106
|
const finalOwned = await scheduler.inventory(identity);
|
|
76
107
|
if (canonicalSchedulerEvidence(finalOwned) !== canonicalSchedulerEvidence(await scheduler.inventory(identity)))
|
|
77
|
-
throw new
|
|
108
|
+
throw new SchedulerDrainFailure('ownership-changed', 'Scheduler ownership changed during proof');
|
|
78
109
|
const activeCount = finalOwned.occurrences.filter(row => ['admitting', 'running'].includes(row.state)).length;
|
|
79
110
|
const unknownCount = finalOwned.occurrences.filter(row => row.state === 'unknown' || row.settlementPending).length
|
|
80
111
|
+ finalOwned.issues.length + observed.issues.length
|
|
81
112
|
+ observed.blockers.filter(source => !EXTERNAL_SCHEDULER_KINDS.has(source.kind)).reduce((sum, source) => sum + source.count, 0);
|
|
82
113
|
if (descriptor.expiresAt <= Date.now())
|
|
83
|
-
throw new
|
|
114
|
+
throw new SchedulerDrainFailure('challenge-expired', 'Scheduler challenge expired', { lateByMs: Date.now() - descriptor.expiresAt });
|
|
115
|
+
step = 'receipt-failed';
|
|
84
116
|
const receipt = { ...descriptor, status: firstBoundaryReady && !activeCount && !unknownCount ? 'managed-drained' : 'pending',
|
|
85
117
|
scope: 'magnus-managed-scheduler-only', activeCount, unknownCount,
|
|
86
118
|
externalBlockers: observed.blockers.filter(source => EXTERNAL_SCHEDULER_KINDS.has(source.kind)),
|
|
@@ -89,8 +121,14 @@ export function schedulerDrainRoutes(options = {}) {
|
|
|
89
121
|
c.header('Cache-Control', 'no-store');
|
|
90
122
|
return c.json({ ...receipt, signature });
|
|
91
123
|
}
|
|
92
|
-
catch {
|
|
93
|
-
|
|
124
|
+
catch (error) {
|
|
125
|
+
const reason = error instanceof SchedulerDrainFailure ? error.reason : step;
|
|
126
|
+
// The local log keeps the real cause; the response carries only the fixed reason.
|
|
127
|
+
logger.warn('Scheduler enrollment drain refused', { reason, error: error instanceof Error ? error.message : String(error),
|
|
128
|
+
...(error instanceof SchedulerDrainFailure ? error.detail : {}), epoch: descriptor.epoch, barrierId: descriptor.barrierId,
|
|
129
|
+
elapsedMs: Date.now() - started });
|
|
130
|
+
return c.json({ success: false, error: { code: 'SCHEDULER_DRAIN_EVIDENCE_UNAVAILABLE', reason,
|
|
131
|
+
message: 'Scheduler evidence remains incomplete; no drain acknowledgment issued' } }, 409);
|
|
94
132
|
}
|
|
95
133
|
});
|
|
96
134
|
return app;
|
|
@@ -13,6 +13,16 @@ export interface SchedulerInventoryOptions {
|
|
|
13
13
|
atSpools?: string[];
|
|
14
14
|
wrapper?: (job: Awaited<ReturnType<ManagedScheduler['job']>> & {}) => string;
|
|
15
15
|
}
|
|
16
|
+
export declare const hermesJobClaimed: (job: Record<string, unknown>) => boolean;
|
|
17
|
+
/** Path-scoped identity of one Hermes cron/jobs.json. Schedules: the canonical
|
|
18
|
+
* job definitions, so adding, editing or enabling a job is a new source while a
|
|
19
|
+
* run-state rewrite is not. Claims: the set of jobs currently holding a claim. */
|
|
20
|
+
export declare function hermesJobsIdentity(path: string, value: unknown): {
|
|
21
|
+
schedulesSha256: string;
|
|
22
|
+
claimsSha256: string;
|
|
23
|
+
enabled: number;
|
|
24
|
+
claims: number;
|
|
25
|
+
};
|
|
16
26
|
export declare function schedulerInventory(options?: SchedulerInventoryOptions): Promise<{
|
|
17
27
|
version: number;
|
|
18
28
|
barrier: {
|
|
@@ -5,6 +5,55 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { hash } from './execution-supervisor-state.js';
|
|
6
6
|
import { aggregateSchedulerBlockers, boundedSchedulerMemberEvidence, boundedSchedulerRead, discoverSchedulerSources, schedulerContentDigest, schedulerMemberEvidenceDigest, schedulerMembersDigest } from './scheduler-discovery.js';
|
|
7
7
|
import { ManagedScheduler } from './managed-scheduler.js';
|
|
8
|
+
/** Per-job fields Hermes rewrites while running or finishing a job. They are
|
|
9
|
+
* run state, not schedule definition: excluding them keeps one remembered source
|
|
10
|
+
* per jobs.json definition instead of one per run. Every other field, including
|
|
11
|
+
* fields this list does not know, is part of the definition. */
|
|
12
|
+
const HERMES_RUN_STATE_FIELDS = new Set(['next_run_at', 'updated_at', 'run_count', 'runs', 'failure_count',
|
|
13
|
+
'consecutive_failures', 'fire_claim', 'run_claim']);
|
|
14
|
+
const hermesRunStateField = (key) => key.startsWith('last_') || HERMES_RUN_STATE_FIELDS.has(key);
|
|
15
|
+
function canonicalJson(value) {
|
|
16
|
+
if (Array.isArray(value))
|
|
17
|
+
return value.map(canonicalJson);
|
|
18
|
+
if (!value || typeof value !== 'object')
|
|
19
|
+
return value;
|
|
20
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalJson(value[key])]));
|
|
21
|
+
}
|
|
22
|
+
function hermesJobDefinition(job) {
|
|
23
|
+
const definition = {};
|
|
24
|
+
for (const [key, value] of Object.entries(job))
|
|
25
|
+
if (!hermesRunStateField(key))
|
|
26
|
+
definition[key] = value;
|
|
27
|
+
// `repeat.times` is configuration; `repeat.completed` is a run counter.
|
|
28
|
+
if (definition['repeat'] && typeof definition['repeat'] === 'object' && !Array.isArray(definition['repeat'])) {
|
|
29
|
+
const { completed: _completed, ...repeat } = definition['repeat'];
|
|
30
|
+
definition['repeat'] = repeat;
|
|
31
|
+
}
|
|
32
|
+
return canonicalJson(definition);
|
|
33
|
+
}
|
|
34
|
+
export const hermesJobClaimed = (job) => Boolean(job['fire_claim'] || job['run_claim'] || ['running', 'working'].includes(job['last_status']));
|
|
35
|
+
/** Path-scoped identity of one Hermes cron/jobs.json. Schedules: the canonical
|
|
36
|
+
* job definitions, so adding, editing or enabling a job is a new source while a
|
|
37
|
+
* run-state rewrite is not. Claims: the set of jobs currently holding a claim. */
|
|
38
|
+
export function hermesJobsIdentity(path, value) {
|
|
39
|
+
const document = Array.isArray(value) ? undefined : value && typeof value === 'object' ? value : undefined;
|
|
40
|
+
const jobs = Array.isArray(value) ? value : document?.['jobs'];
|
|
41
|
+
if (!Array.isArray(jobs) || jobs.length > 1000 || jobs.some(job => !job || typeof job !== 'object' || Array.isArray(job)
|
|
42
|
+
|| job.enabled !== undefined && typeof job.enabled !== 'boolean' || job.paused !== undefined && typeof job.paused !== 'boolean'))
|
|
43
|
+
throw new Error('Invalid native jobs');
|
|
44
|
+
const rows = jobs;
|
|
45
|
+
const definitions = rows.map(job => JSON.stringify(hermesJobDefinition(job))).sort();
|
|
46
|
+
// Top-level document fields other than the job list are kept, except its rewrite stamp.
|
|
47
|
+
const envelope = document ? canonicalJson(Object.fromEntries(Object.entries(document).filter(([key]) => key !== 'jobs' && key !== 'updated_at'))) : null;
|
|
48
|
+
const claimed = rows.filter(hermesJobClaimed).map(job => ['string', 'number'].includes(typeof job['id']) && String(job['id'])
|
|
49
|
+
? 'id:' + String(job['id']) : 'definition:' + hash(JSON.stringify(hermesJobDefinition(job)))).sort();
|
|
50
|
+
return {
|
|
51
|
+
schedulesSha256: schedulerContentDigest(JSON.stringify({ domain: 'magnus.hermes-schedules.v1', source: hash(path), envelope, definitions })),
|
|
52
|
+
claimsSha256: schedulerContentDigest(JSON.stringify({ domain: 'magnus.hermes-claims.v1', source: hash(path), claimed })),
|
|
53
|
+
enabled: rows.filter(job => job['enabled'] !== false && job['paused'] !== true).length,
|
|
54
|
+
claims: claimed.length,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
8
57
|
export async function schedulerInventory(options = {}) {
|
|
9
58
|
const blockers = [];
|
|
10
59
|
const issues = [];
|
|
@@ -147,15 +196,14 @@ export async function schedulerInventory(options = {}) {
|
|
|
147
196
|
if (raw === undefined)
|
|
148
197
|
continue;
|
|
149
198
|
try {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
blockContent('NATIVE_HERMES_UNSETTLED_CLAIMS', raw, jobs.filter(job => job.fire_claim || job.run_claim || ['running', 'working'].includes(job.last_status)).length);
|
|
199
|
+
const identity = hermesJobsIdentity(path, JSON.parse(raw));
|
|
200
|
+
// Hermes rewrites jobs.json on every run. Evidence and remembered identity
|
|
201
|
+
// follow the job definitions and the set of claimed jobs, not run state.
|
|
202
|
+
sources[hash(path)] = schedulerContentDigest(JSON.stringify({ schedules: identity.schedulesSha256, claims: identity.claimsSha256 }));
|
|
203
|
+
blockers.push(...[{ kind: 'NATIVE_HERMES_SCHEDULES', sourceSha256: identity.schedulesSha256, count: identity.enabled },
|
|
204
|
+
// Native claim TTL/PID liveness cannot prove descendants stopped. A paused
|
|
205
|
+
// schedule with any unresolved claim remains uncertain until its owner proves completion.
|
|
206
|
+
{ kind: 'NATIVE_HERMES_UNSETTLED_CLAIMS', sourceSha256: identity.claimsSha256, count: identity.claims }].filter(row => row.count));
|
|
159
207
|
}
|
|
160
208
|
catch {
|
|
161
209
|
issues.push('HERMES_SCHEDULE_INVENTORY_INVALID:' + hash(path));
|
package/dist/hermes-sync.js
CHANGED
|
@@ -7,6 +7,7 @@ import { access, chown, chmod, lstat, mkdir, mkdtemp, readFile, readlink, readdi
|
|
|
7
7
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { parseGatewayStatusText } from './executors/hermes-gateway-status.js';
|
|
10
11
|
import { childEnvironment } from './utils/child-env.js';
|
|
11
12
|
import { armChildTimeout } from './utils/child-timeout.js';
|
|
12
13
|
// Compare real paths: the service may start this file through a link (node_modules -> current/node_modules).
|
|
@@ -667,7 +668,7 @@ async function coreUpdate() {
|
|
|
667
668
|
const current = (await runAsOwner('/usr/bin/git', home, ['-C', checkout, 'rev-parse', 'HEAD'])).trim();
|
|
668
669
|
evidence.beforeVersion = (await runHermes(bin, home, ['--version'])).trim().slice(0, 100);
|
|
669
670
|
const gatewayBefore = await runHermes(bin, home, ['gateway', 'status']);
|
|
670
|
-
evidence.beforeGatewayActive =
|
|
671
|
+
evidence.beforeGatewayActive = parseGatewayStatusText(gatewayBefore) === 'running';
|
|
671
672
|
const pluginsBefore = await runHermes(bin, home, ['plugins', 'list', '--json']);
|
|
672
673
|
const toolsBefore = await toolsSummary(bin, home);
|
|
673
674
|
if (current === slot.commit) {
|
|
@@ -730,7 +731,7 @@ async function coreUpdate() {
|
|
|
730
731
|
}
|
|
731
732
|
evidence.afterVersion = (await runHermes(bin, home, ['--version'])).trim().slice(0, 100);
|
|
732
733
|
const gatewayAfter = await runHermes(bin, home, ['gateway', 'status']);
|
|
733
|
-
evidence.afterGatewayActive =
|
|
734
|
+
evidence.afterGatewayActive = parseGatewayStatusText(gatewayAfter) === 'running';
|
|
734
735
|
const pluginsAfter = await runHermes(bin, home, ['plugins', 'list', '--json']);
|
|
735
736
|
const before = JSON.parse(pluginsBefore);
|
|
736
737
|
const after = JSON.parse(pluginsAfter);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/magnus-agent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "Magnus node agent: a small daemon that enrolls a Linux host with a magnus-server and runs authenticated health, command, cron, file and skill management, with optional Hermes Agent, Codex and Ardent management.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"magnus",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"clean": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\""
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@hivemagnus/magnus-contracts": "1.1.
|
|
61
|
+
"@hivemagnus/magnus-contracts": "1.1.7",
|
|
62
62
|
"@hono/node-server": "^2.1.5",
|
|
63
63
|
"dotenv": "^18.0.9",
|
|
64
64
|
"hono": "^4.13.16",
|