@hivemagnus/magnus-agent 1.1.4 → 1.1.5
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/dist/executors/enrollment-drain.d.ts +2 -0
- package/dist/executors/enrollment-drain.js +24 -6
- package/dist/executors/scheduler-discovery.d.ts +13 -0
- package/dist/executors/scheduler-discovery.js +61 -5
- package/dist/executors/scheduler-drain.d.ts +9 -0
- package/dist/executors/scheduler-drain.js +44 -6
- package/package.json +2 -2
|
@@ -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
|
});
|
|
@@ -46,6 +46,19 @@ 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
|
+
* A process is a possible Hermes runtime when what it executes is Hermes: its
|
|
55
|
+
* executable, the script or module an interpreter runs, or an explicit Hermes
|
|
56
|
+
* home option. Other argument text is data, not identity: an operator's
|
|
57
|
+
* `grep hermes`, a report filter naming a Hermes kind, or a shell whose inline
|
|
58
|
+
* code mentions Hermes is not a runtime (any Hermes it starts is a separate
|
|
59
|
+
* process observed on its own).
|
|
60
|
+
*/
|
|
61
|
+
export declare function hermesRuntimeCommand(args: string[]): boolean;
|
|
49
62
|
export declare function discoverSchedulerSources(options?: DiscoveryOptions): Promise<{
|
|
50
63
|
homes: string[];
|
|
51
64
|
hermesRoots: string[];
|
|
@@ -113,6 +113,61 @@ 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 HERMES_RUNTIME_TEXT = /(?:hermes|run_gateway\.py)/i;
|
|
137
|
+
const SCRIPT_INTERPRETER = /^(?:python(?:[23](?:\.\d+)?)?|pypy3?|node|bun|deno|bash|sh|dash|zsh)$/;
|
|
138
|
+
/**
|
|
139
|
+
* A process is a possible Hermes runtime when what it executes is Hermes: its
|
|
140
|
+
* executable, the script or module an interpreter runs, or an explicit Hermes
|
|
141
|
+
* home option. Other argument text is data, not identity: an operator's
|
|
142
|
+
* `grep hermes`, a report filter naming a Hermes kind, or a shell whose inline
|
|
143
|
+
* code mentions Hermes is not a runtime (any Hermes it starts is a separate
|
|
144
|
+
* process observed on its own).
|
|
145
|
+
*/
|
|
146
|
+
export function hermesRuntimeCommand(args) {
|
|
147
|
+
const executable = args[0] ?? '';
|
|
148
|
+
if (HERMES_RUNTIME_TEXT.test(executable))
|
|
149
|
+
return true;
|
|
150
|
+
if (args.some(arg => /^--hermes-home(?:=|$)/.test(arg)))
|
|
151
|
+
return true;
|
|
152
|
+
if (!SCRIPT_INTERPRETER.test(executable.split('/').pop() ?? ''))
|
|
153
|
+
return false;
|
|
154
|
+
for (let index = 1; index < args.length; index++) {
|
|
155
|
+
const arg = args[index];
|
|
156
|
+
if (arg === '-m')
|
|
157
|
+
return HERMES_RUNTIME_TEXT.test(args[index + 1] ?? '');
|
|
158
|
+
if (/^-m./.test(arg))
|
|
159
|
+
return HERMES_RUNTIME_TEXT.test(arg.slice(2));
|
|
160
|
+
// Inline code is not a script identity.
|
|
161
|
+
if (/^-[a-zA-Z]*[ce]$/.test(arg) || ['--command', '--eval', '--print'].includes(arg))
|
|
162
|
+
return false;
|
|
163
|
+
if (arg === '--')
|
|
164
|
+
return HERMES_RUNTIME_TEXT.test(args[index + 1] ?? '');
|
|
165
|
+
if (arg.startsWith('-'))
|
|
166
|
+
continue;
|
|
167
|
+
return HERMES_RUNTIME_TEXT.test(arg);
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
116
171
|
export async function discoverSchedulerSources(options = {}) {
|
|
117
172
|
const issues = [];
|
|
118
173
|
const blockers = [];
|
|
@@ -207,7 +262,7 @@ export async function discoverSchedulerSources(options = {}) {
|
|
|
207
262
|
'--state=active,activating,reloading', '--all', '--plain', '--no-legend', '--no-pager']);
|
|
208
263
|
const enabledRaw = await run('/usr/bin/systemctl', [...scope, 'list-unit-files', '--type=timer',
|
|
209
264
|
'--state=enabled,enabled-runtime', '--plain', '--no-legend', '--no-pager']);
|
|
210
|
-
sources[sourceKey] = schedulerContentDigest(JSON.stringify([activeRaw, enabledRaw]));
|
|
265
|
+
sources[sourceKey] = schedulerContentDigest(JSON.stringify([stableTimerUnitList(activeRaw), enabledRaw]));
|
|
211
266
|
const names = [...new Set([...timerNames(activeRaw), ...timerNames(enabledRaw)])].sort(compareText);
|
|
212
267
|
for (const name of names) {
|
|
213
268
|
try {
|
|
@@ -416,10 +471,11 @@ export async function discoverSchedulerSources(options = {}) {
|
|
|
416
471
|
if (error.code !== 'ENOENT')
|
|
417
472
|
throw error;
|
|
418
473
|
}
|
|
474
|
+
const stableService = stableServiceProperties(serviceRaw);
|
|
419
475
|
if (await run('/usr/bin/systemctl', showArgs) !== raw
|
|
420
|
-
|| await run('/usr/bin/systemctl', serviceShowArgs) !==
|
|
476
|
+
|| stableServiceProperties(await run('/usr/bin/systemctl', serviceShowArgs)) !== stableService)
|
|
421
477
|
throw new Error('Timer or triggered service identity changed during observation');
|
|
422
|
-
sources[sourceKey + ':' + hash(name)] = schedulerContentDigest(JSON.stringify([raw,
|
|
478
|
+
sources[sourceKey + ':' + hash(name)] = schedulerContentDigest(JSON.stringify([raw, stableService]));
|
|
423
479
|
blockEvidence(kind, evidence, 1);
|
|
424
480
|
}
|
|
425
481
|
catch (error) {
|
|
@@ -628,13 +684,13 @@ export async function discoverSchedulerSources(options = {}) {
|
|
|
628
684
|
const command = await boundedSchedulerRead(join(proc, pid, 'cmdline'));
|
|
629
685
|
if (command.length > 256 * 1024)
|
|
630
686
|
throw new Error('Process command bound');
|
|
631
|
-
if (
|
|
687
|
+
if (hermesRuntimeCommand(command.toString('utf8').split('\0'))) {
|
|
632
688
|
try {
|
|
633
689
|
const startedAt = await processStart(pid);
|
|
634
690
|
const member = await boundedSchedulerMemberEvidence(join(proc, pid, 'cmdline'), `process:${pid}:start:${startedAt}`);
|
|
635
691
|
const verified = await boundedSchedulerRead(join(proc, pid, 'cmdline'));
|
|
636
692
|
if (await processStart(pid) !== startedAt || schedulerContentDigest(verified) !== member.contentSha256
|
|
637
|
-
||
|
|
693
|
+
|| !hermesRuntimeCommand(verified.toString('utf8').split('\0')))
|
|
638
694
|
throw new Error('Process identity changed during observation');
|
|
639
695
|
blockEvidence('NATIVE_HERMES_RUNTIME_UNINSTRUMENTED', [member]);
|
|
640
696
|
}
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/magnus-agent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
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.5",
|
|
62
62
|
"@hono/node-server": "^2.1.5",
|
|
63
63
|
"dotenv": "^18.0.9",
|
|
64
64
|
"hono": "^4.13.16",
|