@getmarrow/install 0.1.64 → 0.1.66
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 +95 -21
- package/package.json +1 -1
- package/src/controller-manager.js +163 -11
- package/src/enforcement-client.js +34 -13
- package/src/first-hour.js +17 -3
- package/src/governance-sidecar.js +134 -18
- package/src/governed-runner.js +430 -99
- package/src/hermes-config.js +568 -0
- package/src/installer.js +875 -385
- package/src/owner-env.js +134 -0
|
@@ -3,8 +3,90 @@ const fs = require('node:fs');
|
|
|
3
3
|
const http = require('node:http');
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const path = require('node:path');
|
|
6
|
+
const { version: INSTALLER_VERSION } = require('../package.json');
|
|
6
7
|
|
|
7
8
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
9
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
10
|
+
const HEARTBEAT_MAX_BACKOFF_MS = 30 * 60_000;
|
|
11
|
+
const HEARTBEAT_CIRCUIT_THRESHOLD = 5;
|
|
12
|
+
const HEARTBEAT_PROBE_MS = 60 * 60_000;
|
|
13
|
+
const CONTROLLER_RESTART = 'npx @getmarrow/install controller stop && npx @getmarrow/install controller ensure';
|
|
14
|
+
|
|
15
|
+
function heartbeatFailure(error) {
|
|
16
|
+
const status = Number.isInteger(error?.status) ? error.status : null;
|
|
17
|
+
const rawCode = error?.details?.code || error?.code;
|
|
18
|
+
const code = typeof rawCode === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(rawCode) ? rawCode : null;
|
|
19
|
+
return {
|
|
20
|
+
status,
|
|
21
|
+
code,
|
|
22
|
+
// A 4xx other than timeout or rate limit repeats until the request or its identity changes.
|
|
23
|
+
permanent: status !== null && status >= 400 && status < 500 && status !== 408 && status !== 429,
|
|
24
|
+
signature: `${status ?? 'network'}:${code ?? ''}`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function heartbeatExactFix({ status, code }) {
|
|
29
|
+
const failure = `HTTP ${status}${code ? ` ${code}` : ''}`;
|
|
30
|
+
if (status === 404) {
|
|
31
|
+
return `Marrow has no registered agent for this controller (${failure}). Create the agent or bind this API key to a registered agent, set MARROW_FLEET_AGENT_ID to its id if the key is not bound, then run ${CONTROLLER_RESTART} to retry now.`;
|
|
32
|
+
}
|
|
33
|
+
if (status === 401 || status === 403) {
|
|
34
|
+
return `Marrow rejected this controller's API key or agent scope (${failure}). Use an active key bound to the registered agent, then run ${CONTROLLER_RESTART} to retry now.`;
|
|
35
|
+
}
|
|
36
|
+
return `Marrow rejected the controller heartbeat (${failure}). Run npx -y @getmarrow/install@latest update, then ${CONTROLLER_RESTART} to retry now.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Heartbeats back off exponentially with jitter after any failure. Repeated identical 4xx
|
|
40
|
+
// responses open the circuit: heartbeats then drop to one jittered probe about every hour,
|
|
41
|
+
// so a fleet-wide transient 401/403/404 recovers without restarts. 5xx, 429, and network
|
|
42
|
+
// failures keep retrying at the capped backoff. A notice is returned only when the circuit
|
|
43
|
+
// opens, when its fix changes, and when heartbeats recover.
|
|
44
|
+
function createHeartbeatPolicy({
|
|
45
|
+
intervalMs = HEARTBEAT_INTERVAL_MS,
|
|
46
|
+
maxBackoffMs = HEARTBEAT_MAX_BACKOFF_MS,
|
|
47
|
+
circuitThreshold = HEARTBEAT_CIRCUIT_THRESHOLD,
|
|
48
|
+
probeMs = HEARTBEAT_PROBE_MS,
|
|
49
|
+
random = Math.random,
|
|
50
|
+
} = {}) {
|
|
51
|
+
let consecutive = 0;
|
|
52
|
+
let identical = 0;
|
|
53
|
+
let last = null;
|
|
54
|
+
let openFix = null;
|
|
55
|
+
return {
|
|
56
|
+
get open() { return openFix !== null; },
|
|
57
|
+
success() {
|
|
58
|
+
const recovered = openFix !== null;
|
|
59
|
+
consecutive = 0;
|
|
60
|
+
identical = 0;
|
|
61
|
+
last = null;
|
|
62
|
+
openFix = null;
|
|
63
|
+
return {
|
|
64
|
+
delayMs: intervalMs,
|
|
65
|
+
state: { state: 'ok', failures: 0, exact_fix: null },
|
|
66
|
+
notice: recovered ? 'Marrow controller resumed enforcement heartbeats.' : null,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
failure(error) {
|
|
70
|
+
const failure = heartbeatFailure(error);
|
|
71
|
+
consecutive += 1;
|
|
72
|
+
identical = last?.signature === failure.signature ? identical + 1 : 1;
|
|
73
|
+
last = failure;
|
|
74
|
+
const summary = { status: failure.status, code: failure.code, failures: identical };
|
|
75
|
+
if (openFix !== null || (failure.permanent && identical >= circuitThreshold)) {
|
|
76
|
+
const fix = failure.permanent ? heartbeatExactFix(failure) : openFix;
|
|
77
|
+
const notice = fix === openFix
|
|
78
|
+
? null
|
|
79
|
+
: `Marrow controller paused enforcement heartbeats and retries about once an hour. ${fix}`;
|
|
80
|
+
openFix = fix;
|
|
81
|
+
const delayMs = Math.round(probeMs * (0.75 + random() * 0.5));
|
|
82
|
+
return { delayMs, state: { state: 'circuit_open', ...summary, retry_in_ms: delayMs, exact_fix: fix }, notice };
|
|
83
|
+
}
|
|
84
|
+
const ceiling = Math.min(maxBackoffMs, intervalMs * 2 ** consecutive);
|
|
85
|
+
const delayMs = Math.round(ceiling / 2 + random() * (ceiling / 2));
|
|
86
|
+
return { delayMs, state: { state: 'backing_off', ...summary, retry_in_ms: delayMs, exact_fix: null }, notice: null };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
8
90
|
|
|
9
91
|
function sidecarStateDir() {
|
|
10
92
|
return process.env.MARROW_SIDECAR_STATE_DIR || path.join(os.homedir(), '.marrow', 'sidecar');
|
|
@@ -142,6 +224,7 @@ async function startGovernanceSidecar(options, handlers) {
|
|
|
142
224
|
repaired: [],
|
|
143
225
|
exact_fix: handlers.maintain ? null : 'Run npx @getmarrow/install --repair in the managed project.',
|
|
144
226
|
};
|
|
227
|
+
let latestHeartbeat = { state: 'pending', failures: 0, exact_fix: null, checked_at: null };
|
|
145
228
|
|
|
146
229
|
const server = http.createServer(async (req, res) => {
|
|
147
230
|
try {
|
|
@@ -158,7 +241,9 @@ async function startGovernanceSidecar(options, handlers) {
|
|
|
158
241
|
instance_id: instanceId,
|
|
159
242
|
pid: process.pid,
|
|
160
243
|
started_at: startedAt,
|
|
244
|
+
installer_version: INSTALLER_VERSION,
|
|
161
245
|
maintenance: latestMaintenance,
|
|
246
|
+
heartbeat: latestHeartbeat,
|
|
162
247
|
});
|
|
163
248
|
}
|
|
164
249
|
if (req.method === 'GET' && url.pathname === '/coverage') {
|
|
@@ -194,33 +279,64 @@ async function startGovernanceSidecar(options, handlers) {
|
|
|
194
279
|
port: boundPort,
|
|
195
280
|
token: authToken,
|
|
196
281
|
started_at: startedAt,
|
|
282
|
+
installer_version: INSTALLER_VERSION,
|
|
283
|
+
...(process.env.MARROW_CONTROLLER_PROJECT_ROOT
|
|
284
|
+
? { project_root: path.resolve(process.env.MARROW_CONTROLLER_PROJECT_ROOT) }
|
|
285
|
+
: {}),
|
|
197
286
|
});
|
|
198
287
|
} catch (error) {
|
|
199
288
|
await new Promise((resolve) => server.close(resolve));
|
|
200
289
|
throw error;
|
|
201
290
|
}
|
|
202
291
|
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
292
|
+
const heartbeatPolicy = createHeartbeatPolicy({
|
|
293
|
+
intervalMs: options.heartbeatIntervalMs,
|
|
294
|
+
probeMs: options.heartbeatProbeMs,
|
|
295
|
+
random: options.heartbeatRandom,
|
|
296
|
+
});
|
|
297
|
+
let nextHeartbeatAt = 0;
|
|
298
|
+
// Maintenance and heartbeat have separate in-flight guards, so a hung heartbeat request
|
|
299
|
+
// never stalls drift repair.
|
|
300
|
+
let maintenanceRunning = false;
|
|
301
|
+
let heartbeatRunning = false;
|
|
302
|
+
const runMaintenance = async () => {
|
|
303
|
+
if (!handlers.maintain || maintenanceRunning) return;
|
|
304
|
+
maintenanceRunning = true;
|
|
216
305
|
try {
|
|
217
|
-
|
|
306
|
+
latestMaintenance = await handlers.maintain();
|
|
218
307
|
} catch {
|
|
219
|
-
|
|
308
|
+
latestMaintenance = {
|
|
309
|
+
state: 'attention_required',
|
|
310
|
+
checked_at: new Date().toISOString(),
|
|
311
|
+
repaired: [],
|
|
312
|
+
exact_fix: 'Run npx @getmarrow/install --repair in the managed project.',
|
|
313
|
+
};
|
|
314
|
+
} finally {
|
|
315
|
+
maintenanceRunning = false;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
const runHeartbeat = async () => {
|
|
319
|
+
if (heartbeatRunning || Date.now() < nextHeartbeatAt) return;
|
|
320
|
+
heartbeatRunning = true;
|
|
321
|
+
try {
|
|
322
|
+
let result;
|
|
323
|
+
try {
|
|
324
|
+
latestCoverage = await handlers.heartbeat({ sidecarInstanceId: instanceId });
|
|
325
|
+
result = heartbeatPolicy.success();
|
|
326
|
+
} catch (error) {
|
|
327
|
+
// Coverage will mark a stale heartbeat; never weaken execution policy here.
|
|
328
|
+
result = heartbeatPolicy.failure(error);
|
|
329
|
+
}
|
|
330
|
+
latestHeartbeat = { ...result.state, checked_at: new Date().toISOString() };
|
|
331
|
+
nextHeartbeatAt = Date.now() + result.delayMs;
|
|
332
|
+
if (result.notice) process.stderr.write(`${result.notice}\n`);
|
|
333
|
+
} finally {
|
|
334
|
+
heartbeatRunning = false;
|
|
220
335
|
}
|
|
221
336
|
};
|
|
222
|
-
|
|
223
|
-
|
|
337
|
+
const tick = () => Promise.all([runMaintenance(), runHeartbeat()]);
|
|
338
|
+
await tick();
|
|
339
|
+
const timer = setInterval(tick, options.heartbeatIntervalMs || HEARTBEAT_INTERVAL_MS);
|
|
224
340
|
timer.unref();
|
|
225
341
|
|
|
226
342
|
let closed = false;
|
|
@@ -239,4 +355,4 @@ async function startGovernanceSidecar(options, handlers) {
|
|
|
239
355
|
return { server, instanceId, port: boundPort, stateFile, close };
|
|
240
356
|
}
|
|
241
357
|
|
|
242
|
-
module.exports = { startGovernanceSidecar, sidecarStateDir };
|
|
358
|
+
module.exports = { startGovernanceSidecar, sidecarStateDir, createHeartbeatPolicy };
|