wolverine-ai 4.4.0 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/agent-engine.js +68 -15
- package/src/brain/brain.js +1 -1
- package/src/core/error-hook.js +40 -0
- package/src/monitor/adaptive-limiter.js +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.1",
|
|
4
4
|
"description": "Self-healing Node.js server framework powered by AI. Catches crashes, diagnoses errors, generates fixes, verifies, and restarts — automatically.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -477,6 +477,13 @@ const BLOCKED_COMMANDS = [
|
|
|
477
477
|
/\bnpm\s+publish\b/i, // no accidental publishes
|
|
478
478
|
/\bcurl\b.*\|\s*bash/i, // pipe to bash
|
|
479
479
|
/\beval\s*\(/i,
|
|
480
|
+
/wget.*\|\s*sh/i, // wget pipe to shell
|
|
481
|
+
/curl.*\$\(/i, // curl data exfiltration via command substitution
|
|
482
|
+
/cat\s+\.env/i, // read secrets via bash
|
|
483
|
+
/>\s*src\//i, // redirect write to src/
|
|
484
|
+
/cp\s+.*\s+src\//i, // copy into src/
|
|
485
|
+
/tee\s+.*src\//i, // tee into src/
|
|
486
|
+
/mv\s+.*\s+src\//i, // move into src/
|
|
480
487
|
];
|
|
481
488
|
|
|
482
489
|
class AgentEngine {
|
|
@@ -885,7 +892,7 @@ class AgentEngine {
|
|
|
885
892
|
|
|
886
893
|
try {
|
|
887
894
|
this.sandbox.resolve(fullPath);
|
|
888
|
-
const content =
|
|
895
|
+
const content = this.sandbox.readFile(fullPath);
|
|
889
896
|
const lines = content.split("\n");
|
|
890
897
|
const relPath = path.relative(this.cwd, fullPath).replace(/\\/g, "/");
|
|
891
898
|
|
|
@@ -946,7 +953,7 @@ class AgentEngine {
|
|
|
946
953
|
|
|
947
954
|
_gitLog(args) {
|
|
948
955
|
const count = args.count || 10;
|
|
949
|
-
const fileFilter = args.file ? ` -- ${args.file}` : "";
|
|
956
|
+
const fileFilter = args.file ? ` -- "${args.file}"` : "";
|
|
950
957
|
try {
|
|
951
958
|
const output = execSync(
|
|
952
959
|
`git log --oneline --no-decorate -n ${count}${fileFilter}`,
|
|
@@ -960,8 +967,8 @@ class AgentEngine {
|
|
|
960
967
|
}
|
|
961
968
|
|
|
962
969
|
_gitDiff(args) {
|
|
963
|
-
const ref = args.ref
|
|
964
|
-
const fileFilter = args.file ? ` -- ${args.file}` : "";
|
|
970
|
+
const ref = args.ref ? `"${args.ref}"` : "";
|
|
971
|
+
const fileFilter = args.file ? ` -- "${args.file}"` : "";
|
|
965
972
|
try {
|
|
966
973
|
const output = execSync(
|
|
967
974
|
`git diff ${ref}${fileFilter}`,
|
|
@@ -984,6 +991,30 @@ class AgentEngine {
|
|
|
984
991
|
return;
|
|
985
992
|
}
|
|
986
993
|
|
|
994
|
+
// SSRF protection: block private/internal IPs
|
|
995
|
+
try {
|
|
996
|
+
const parsedUrl = new (require("url").URL)(url);
|
|
997
|
+
const hostname = parsedUrl.hostname;
|
|
998
|
+
const privatePatterns = [
|
|
999
|
+
/^127\./,
|
|
1000
|
+
/^localhost$/i,
|
|
1001
|
+
/^169\.254\.169\.254$/,
|
|
1002
|
+
/^100\.100\.100\.200$/,
|
|
1003
|
+
/^10\./,
|
|
1004
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
1005
|
+
/^192\.168\./,
|
|
1006
|
+
/^fd[0-9a-f]{2}:/i,
|
|
1007
|
+
/^::1$/,
|
|
1008
|
+
];
|
|
1009
|
+
if (privatePatterns.some(p => p.test(hostname))) {
|
|
1010
|
+
resolve({ content: `BLOCKED: Cannot fetch private/internal address "${hostname}"` });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
} catch (e) {
|
|
1014
|
+
resolve({ content: `Error parsing URL: ${e.message}` });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
987
1018
|
const client = url.startsWith("https") ? https : http;
|
|
988
1019
|
const req = client.get(url, { timeout: 10000 }, (res) => {
|
|
989
1020
|
let data = "";
|
|
@@ -1020,6 +1051,7 @@ class AgentEngine {
|
|
|
1020
1051
|
_listDir(args) {
|
|
1021
1052
|
const dirPath = path.resolve(this.cwd, args.path || ".");
|
|
1022
1053
|
try {
|
|
1054
|
+
this.sandbox.resolve(dirPath);
|
|
1023
1055
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
1024
1056
|
const lines = entries.map(e => {
|
|
1025
1057
|
try {
|
|
@@ -1066,15 +1098,18 @@ class AgentEngine {
|
|
|
1066
1098
|
|
|
1067
1099
|
_checkEnv(args) {
|
|
1068
1100
|
const { redact } = require("../security/secret-redactor");
|
|
1101
|
+
const isSecretKey = (k) => /KEY|SECRET|TOKEN|PASSWORD|AUTH|CREDENTIAL/i.test(k);
|
|
1069
1102
|
if (args.variable) {
|
|
1070
1103
|
const val = process.env[args.variable];
|
|
1071
|
-
|
|
1104
|
+
if (!val) return { content: `${args.variable}=(not set)` };
|
|
1105
|
+
const display = isSecretKey(args.variable) ? "SET (redacted)" : redact(val);
|
|
1072
1106
|
return { content: `${args.variable}=${display}` };
|
|
1073
1107
|
}
|
|
1074
1108
|
// List all env vars with redacted values
|
|
1075
1109
|
const keys = Object.keys(process.env).sort();
|
|
1076
1110
|
const lines = keys.map(k => {
|
|
1077
1111
|
const val = process.env[k];
|
|
1112
|
+
if (isSecretKey(k)) return `${k}=${val ? "SET (redacted)" : "(not set)"}`;
|
|
1078
1113
|
return `${k}=${val && val.length > 50 ? "(set, " + val.length + " chars)" : redact(val || "")}`;
|
|
1079
1114
|
});
|
|
1080
1115
|
return { content: lines.join("\n") };
|
|
@@ -1101,6 +1136,9 @@ class AgentEngine {
|
|
|
1101
1136
|
if (!upper.startsWith("SELECT") && !upper.startsWith("PRAGMA")) {
|
|
1102
1137
|
return { content: "BLOCKED: inspect_db only allows SELECT/PRAGMA. Use run_db_fix for writes." };
|
|
1103
1138
|
}
|
|
1139
|
+
if (args.sql.includes(";")) {
|
|
1140
|
+
return { content: "BLOCKED: inspect_db does not allow stacked queries (multiple statements separated by ';')." };
|
|
1141
|
+
}
|
|
1104
1142
|
const rows = db.prepare(args.sql).all();
|
|
1105
1143
|
result = JSON.stringify(rows.slice(0, 50), null, 2);
|
|
1106
1144
|
if (rows.length > 50) result += `\n... (${rows.length} total rows, showing first 50)`;
|
|
@@ -1129,7 +1167,9 @@ class AgentEngine {
|
|
|
1129
1167
|
|
|
1130
1168
|
// Backup the DB file first
|
|
1131
1169
|
const backupPath = dbPath + ".wolverine-backup";
|
|
1132
|
-
fs.
|
|
1170
|
+
if (fs.existsSync(dbPath)) {
|
|
1171
|
+
fs.copyFileSync(dbPath, backupPath);
|
|
1172
|
+
}
|
|
1133
1173
|
|
|
1134
1174
|
const db = new Database(dbPath);
|
|
1135
1175
|
|
|
@@ -1258,7 +1298,7 @@ class AgentEngine {
|
|
|
1258
1298
|
}
|
|
1259
1299
|
|
|
1260
1300
|
_checkLogs(args) {
|
|
1261
|
-
const lines = args.lines || 50;
|
|
1301
|
+
const lines = Math.max(1, Math.min(parseInt(args.lines, 10) || 50, 1000));
|
|
1262
1302
|
const filter = args.filter || "";
|
|
1263
1303
|
try {
|
|
1264
1304
|
let cmd;
|
|
@@ -1291,8 +1331,9 @@ class AgentEngine {
|
|
|
1291
1331
|
if (args.host) {
|
|
1292
1332
|
try {
|
|
1293
1333
|
const dns = require("dns");
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1334
|
+
const safeHost = args.host.replace(/[^a-zA-Z0-9.:\/-_]/g, "");
|
|
1335
|
+
const addresses = execSync(`node -e "require('dns').resolve('${safeHost.replace(/'/g, "")}', (e,a) => console.log(e ? 'FAIL:'+e.code : a.join(',')))"`, { encoding: "utf-8", timeout: 5000 }).trim();
|
|
1336
|
+
results.push(`DNS ${safeHost}: ${addresses}`);
|
|
1296
1337
|
} catch (e) { results.push(`DNS ${args.host}: FAILED — ${e.message}`); }
|
|
1297
1338
|
}
|
|
1298
1339
|
// Port check
|
|
@@ -1308,8 +1349,9 @@ class AgentEngine {
|
|
|
1308
1349
|
// URL reachability
|
|
1309
1350
|
if (args.url) {
|
|
1310
1351
|
try {
|
|
1311
|
-
const
|
|
1312
|
-
|
|
1352
|
+
const safeUrl = args.url.replace(/[^a-zA-Z0-9.:\/-_]/g, "");
|
|
1353
|
+
const urlResult = execSync(`node -e "require('${safeUrl.startsWith('https') ? 'https' : 'http'}').get('${safeUrl.replace(/'/g, "")}', r => { console.log(r.statusCode); r.resume(); }).on('error', e => console.log('FAIL:'+e.code))"`, { encoding: "utf-8", timeout: 10000 }).trim();
|
|
1354
|
+
results.push(`URL ${safeUrl}: ${urlResult}`);
|
|
1313
1355
|
} catch (e) { results.push(`URL ${args.url}: FAILED — ${e.message}`); }
|
|
1314
1356
|
}
|
|
1315
1357
|
if (results.length === 0) results.push("Provide host, port, or url to check.");
|
|
@@ -1358,7 +1400,15 @@ class AgentEngine {
|
|
|
1358
1400
|
if (fs.existsSync(binDir)) {
|
|
1359
1401
|
for (const bin of fs.readdirSync(binDir)) {
|
|
1360
1402
|
const target = path.join(binDir, bin);
|
|
1361
|
-
try {
|
|
1403
|
+
try {
|
|
1404
|
+
if (process.platform === "win32") {
|
|
1405
|
+
// Windows uses .cmd shims instead of symlinks
|
|
1406
|
+
const cmdFile = target.endsWith(".cmd") ? target : target + ".cmd";
|
|
1407
|
+
if (!fs.existsSync(cmdFile) && !fs.existsSync(target)) brokenBins++;
|
|
1408
|
+
} else {
|
|
1409
|
+
fs.readlinkSync(target);
|
|
1410
|
+
}
|
|
1411
|
+
} catch { brokenBins++; }
|
|
1362
1412
|
}
|
|
1363
1413
|
}
|
|
1364
1414
|
const rec = missing.length > 5 || broken.length > 3 ? "rm -rf node_modules && npm install" : missing.length > 0 ? "npm install" : "ok";
|
|
@@ -1475,7 +1525,10 @@ class AgentEngine {
|
|
|
1475
1525
|
try { execSync(`rm -rf "${t.path.replace("~", os.homedir())}"`, { timeout: 10000 }); } catch {}
|
|
1476
1526
|
}
|
|
1477
1527
|
}
|
|
1478
|
-
|
|
1528
|
+
let diskFree;
|
|
1529
|
+
try {
|
|
1530
|
+
diskFree = Math.round(parseInt(execSync("df -m . | tail -1 | awk '{print $4}'", { encoding: "utf-8", cwd: this.cwd, timeout: 3000 }).trim() || "0", 10));
|
|
1531
|
+
} catch { diskFree = "unknown"; }
|
|
1479
1532
|
const lines = [
|
|
1480
1533
|
`Disk free: ${diskFree}MB`,
|
|
1481
1534
|
`Reclaimable: ${reclaimable}MB (${targets.length} targets)`,
|
|
@@ -1653,7 +1706,7 @@ class AgentEngine {
|
|
|
1653
1706
|
function _simplePrompt(cwd, primaryFile) {
|
|
1654
1707
|
return `You are Wolverine, a Node.js server repair agent. Fix the error using minimal changes.
|
|
1655
1708
|
|
|
1656
|
-
TOOLS: read_file, write_file, edit_file, glob_files, grep_code, bash_exec, done
|
|
1709
|
+
TOOLS: read_file, write_file, edit_file, glob_files, grep_code, bash_exec, done + 24 more diagnostic tools available
|
|
1657
1710
|
RULES: Read the file before editing. Use edit_file for targeted fixes. Call done when finished. Use multiple tools at once when independent.
|
|
1658
1711
|
${primaryFile ? `File: ${primaryFile}` : ""}
|
|
1659
1712
|
Project: ${cwd}`;
|
|
@@ -1669,7 +1722,7 @@ CRITICAL: Act fast. You have limited turns. Fix immediately when the solution is
|
|
|
1669
1722
|
|
|
1670
1723
|
For maximum efficiency, invoke multiple independent tools simultaneously rather than sequentially.
|
|
1671
1724
|
|
|
1672
|
-
TOOLS: read_file, write_file, edit_file, glob_files, grep_code, list_dir, move_file, bash_exec, git_log, git_diff, inspect_db, run_db_fix, check_port, check_env, audit_deps, check_migration, web_fetch, done
|
|
1725
|
+
TOOLS: read_file, write_file, edit_file, glob_files, grep_code, list_dir, move_file, bash_exec, git_log, git_diff, inspect_db, run_db_fix, check_port, check_env, audit_deps, check_migration, web_fetch, check_memory, list_processes, check_logs, restart_service, check_network, inspect_env, verify_node_modules, inspect_certificate, inspect_cache, disk_cleanup, check_file_descriptors, check_event_loop, check_websocket, done
|
|
1673
1726
|
|
|
1674
1727
|
FAST FIXES (act immediately, don't investigate):
|
|
1675
1728
|
- Cannot find module 'X' → bash_exec: npm install X → done
|
package/src/brain/brain.js
CHANGED
|
@@ -118,7 +118,7 @@ const SEED_DOCS = [
|
|
|
118
118
|
metadata: { topic: "heal-escalation" },
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
|
-
text: "Process manager: wolverine monitors memory (RSS/heap) every 10s, detects memory leaks (N consecutive growth samples → auto-restart), enforces memory limit (default 512MB), tracks CPU%, probes all routes every 30s, detects response time degradation trends (stable/degrading/improving).
|
|
121
|
+
text: "Process manager: wolverine monitors memory (RSS/heap) every 10s, detects memory leaks (N consecutive growth samples → auto-restart), enforces memory limit (default 512MB), tracks CPU%, probes all routes every 30s, detects response time degradation trends (stable/degrading/improving). Adaptive rate limiter: auto-injected via error-hook.js into Fastify/Express. Three zones based on CPU + memory: GREEN (<70%) = full throughput, YELLOW (70-85%) = shed 30% of requests, RED (>85%) = reject non-essential with 503 + Retry-After. Always allows health checks and wolverine internal routes. Reserves 200MB for heal tools. Enable: WOLVERINE_ADAPTIVE_LIMIT=true (default). Disable: WOLVERINE_ADAPTIVE_LIMIT=false. Response includes X-Wolverine-Zone header.",
|
|
122
122
|
metadata: { topic: "process-manager" },
|
|
123
123
|
},
|
|
124
124
|
{
|
package/src/core/error-hook.js
CHANGED
|
@@ -72,6 +72,28 @@ Module._load = function (request, parent, isMain) {
|
|
|
72
72
|
};
|
|
73
73
|
|
|
74
74
|
function _hookFastify(fastify) {
|
|
75
|
+
// Adaptive rate limiter — auto-protects based on CPU/memory pressure
|
|
76
|
+
if (process.env.WOLVERINE_ADAPTIVE_LIMIT !== "false") {
|
|
77
|
+
try {
|
|
78
|
+
const { getLimiter } = require("../monitor/adaptive-limiter");
|
|
79
|
+
const limiter = getLimiter();
|
|
80
|
+
fastify.addHook("onRequest", function (request, reply, done) {
|
|
81
|
+
if (!limiter.shouldAllow(request.url, request.headers)) {
|
|
82
|
+
const status = limiter.getStatus();
|
|
83
|
+
reply.code(503).header("Retry-After", "5").header("X-Wolverine-Zone", status.zone).send({
|
|
84
|
+
error: "Service temporarily unavailable",
|
|
85
|
+
zone: status.zone,
|
|
86
|
+
cpu: status.cpuAvg + "%",
|
|
87
|
+
memory: status.memAvg + "%",
|
|
88
|
+
retry_after: 5,
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
done();
|
|
93
|
+
});
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
// Wrap setErrorHandler so our IPC reporting runs BEFORE the user's handler
|
|
76
98
|
const origSetError = fastify.setErrorHandler;
|
|
77
99
|
let customErrorHandlerSet = false;
|
|
@@ -107,6 +129,24 @@ function _hookFastify(fastify) {
|
|
|
107
129
|
}
|
|
108
130
|
|
|
109
131
|
function _hookExpress(app) {
|
|
132
|
+
// Adaptive rate limiter for Express
|
|
133
|
+
if (process.env.WOLVERINE_ADAPTIVE_LIMIT !== "false") {
|
|
134
|
+
try {
|
|
135
|
+
const { getLimiter } = require("../monitor/adaptive-limiter");
|
|
136
|
+
const limiter = getLimiter();
|
|
137
|
+
app.use(function _wolverineAdaptiveLimiter(req, res, next) {
|
|
138
|
+
if (!limiter.shouldAllow(req.url, req.headers)) {
|
|
139
|
+
const status = limiter.getStatus();
|
|
140
|
+
res.status(503).set("Retry-After", "5").set("X-Wolverine-Zone", status.zone).json({
|
|
141
|
+
error: "Service temporarily unavailable", zone: status.zone, retry_after: 5,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
next();
|
|
146
|
+
});
|
|
147
|
+
} catch {}
|
|
148
|
+
}
|
|
149
|
+
|
|
110
150
|
// Wrap app.listen to inject error middleware AFTER all user middleware
|
|
111
151
|
const originalListen = app.listen;
|
|
112
152
|
app.listen = function (...args) {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const os = require("os");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adaptive Rate Limiter — auto-protects server based on system resources.
|
|
5
|
+
*
|
|
6
|
+
* Injected into child server via error-hook.js. Monitors CPU and memory
|
|
7
|
+
* in real-time and throttles incoming requests when the server is under
|
|
8
|
+
* pressure, reserving ~20% headroom for wolverine's heal tools.
|
|
9
|
+
*
|
|
10
|
+
* Three zones:
|
|
11
|
+
* GREEN (< 70% resources) — no limiting, full throughput
|
|
12
|
+
* YELLOW (70-85%) — shed new connections gradually
|
|
13
|
+
* RED (> 85%) — reject non-essential requests with 503
|
|
14
|
+
*
|
|
15
|
+
* Enable: WOLVERINE_ADAPTIVE_LIMIT=true (or settings.json adaptiveLimiter.enabled)
|
|
16
|
+
* Disable: WOLVERINE_ADAPTIVE_LIMIT=false (default: enabled)
|
|
17
|
+
*
|
|
18
|
+
* The limiter NEVER blocks:
|
|
19
|
+
* - Health check routes (/health, /healthz, /ready)
|
|
20
|
+
* - Wolverine internal routes (/api/v1/heartbeat, /api/v1/register)
|
|
21
|
+
* - Requests with X-Wolverine-Internal header
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SAMPLE_INTERVAL_MS = 5000;
|
|
25
|
+
const HISTORY_SIZE = 12; // 1 minute of samples at 5s intervals
|
|
26
|
+
const EXEMPT_PATHS = new Set(["/health", "/healthz", "/ready", "/api/v1/heartbeat", "/api/v1/register"]);
|
|
27
|
+
|
|
28
|
+
class AdaptiveLimiter {
|
|
29
|
+
constructor(opts = {}) {
|
|
30
|
+
this.enabled = opts.enabled !== false;
|
|
31
|
+
this.thresholdYellow = opts.thresholdYellow || 70; // % — start shedding
|
|
32
|
+
this.thresholdRed = opts.thresholdRed || 85; // % — reject non-essential
|
|
33
|
+
this.reserveMB = opts.reserveMB || 200; // MB reserved for wolverine tools
|
|
34
|
+
|
|
35
|
+
this._cpuHistory = [];
|
|
36
|
+
this._memHistory = [];
|
|
37
|
+
this._lastCpuTimes = os.cpus().map(c => ({ idle: c.times.idle, total: Object.values(c.times).reduce((a, b) => a + b) }));
|
|
38
|
+
this._zone = "green";
|
|
39
|
+
this._requestCount = 0;
|
|
40
|
+
this._rejectedCount = 0;
|
|
41
|
+
this._timer = null;
|
|
42
|
+
|
|
43
|
+
if (this.enabled) {
|
|
44
|
+
this._timer = setInterval(() => this._sample(), SAMPLE_INTERVAL_MS);
|
|
45
|
+
this._timer.unref(); // don't prevent process exit
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_sample() {
|
|
50
|
+
// CPU usage
|
|
51
|
+
const cpus = os.cpus();
|
|
52
|
+
let totalIdle = 0, totalTick = 0;
|
|
53
|
+
for (let i = 0; i < cpus.length; i++) {
|
|
54
|
+
const prev = this._lastCpuTimes[i] || { idle: 0, total: 0 };
|
|
55
|
+
const total = Object.values(cpus[i].times).reduce((a, b) => a + b);
|
|
56
|
+
const idle = cpus[i].times.idle;
|
|
57
|
+
totalIdle += idle - prev.idle;
|
|
58
|
+
totalTick += total - prev.total;
|
|
59
|
+
this._lastCpuTimes[i] = { idle, total };
|
|
60
|
+
}
|
|
61
|
+
const cpuPct = totalTick > 0 ? Math.round((1 - totalIdle / totalTick) * 100) : 0;
|
|
62
|
+
|
|
63
|
+
// Memory usage (% of total, accounting for reserve)
|
|
64
|
+
const totalMem = os.totalmem();
|
|
65
|
+
const freeMem = os.freemem();
|
|
66
|
+
const reserveBytes = this.reserveMB * 1048576;
|
|
67
|
+
const effectiveFree = Math.max(0, freeMem - reserveBytes);
|
|
68
|
+
const memPct = Math.round((1 - effectiveFree / totalMem) * 100);
|
|
69
|
+
|
|
70
|
+
this._cpuHistory.push(cpuPct);
|
|
71
|
+
this._memHistory.push(memPct);
|
|
72
|
+
if (this._cpuHistory.length > HISTORY_SIZE) this._cpuHistory.shift();
|
|
73
|
+
if (this._memHistory.length > HISTORY_SIZE) this._memHistory.shift();
|
|
74
|
+
|
|
75
|
+
// Use max of CPU and memory pressure
|
|
76
|
+
const avgCpu = this._cpuHistory.reduce((a, b) => a + b, 0) / this._cpuHistory.length;
|
|
77
|
+
const avgMem = this._memHistory.reduce((a, b) => a + b, 0) / this._memHistory.length;
|
|
78
|
+
const pressure = Math.max(avgCpu, avgMem);
|
|
79
|
+
|
|
80
|
+
if (pressure >= this.thresholdRed) this._zone = "red";
|
|
81
|
+
else if (pressure >= this.thresholdYellow) this._zone = "yellow";
|
|
82
|
+
else this._zone = "green";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Middleware function for Fastify (onRequest hook) or Express.
|
|
87
|
+
* Returns true if request should be allowed, false if rejected.
|
|
88
|
+
*/
|
|
89
|
+
shouldAllow(url, headers) {
|
|
90
|
+
if (!this.enabled) return true;
|
|
91
|
+
|
|
92
|
+
this._requestCount++;
|
|
93
|
+
|
|
94
|
+
// Always allow exempt paths and internal requests
|
|
95
|
+
const path = (url || "").split("?")[0];
|
|
96
|
+
if (EXEMPT_PATHS.has(path)) return true;
|
|
97
|
+
if (headers?.["x-wolverine-internal"]) return true;
|
|
98
|
+
|
|
99
|
+
if (this._zone === "green") return true;
|
|
100
|
+
|
|
101
|
+
if (this._zone === "yellow") {
|
|
102
|
+
// Probabilistic shedding — reject ~30% of requests
|
|
103
|
+
if (Math.random() < 0.3) {
|
|
104
|
+
this._rejectedCount++;
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// RED zone — reject all non-exempt
|
|
111
|
+
this._rejectedCount++;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get current limiter status for health endpoints / dashboard.
|
|
117
|
+
*/
|
|
118
|
+
getStatus() {
|
|
119
|
+
return {
|
|
120
|
+
enabled: this.enabled,
|
|
121
|
+
zone: this._zone,
|
|
122
|
+
cpuAvg: this._cpuHistory.length > 0 ? Math.round(this._cpuHistory.reduce((a, b) => a + b, 0) / this._cpuHistory.length) : 0,
|
|
123
|
+
memAvg: this._memHistory.length > 0 ? Math.round(this._memHistory.reduce((a, b) => a + b, 0) / this._memHistory.length) : 0,
|
|
124
|
+
totalRequests: this._requestCount,
|
|
125
|
+
rejectedRequests: this._rejectedCount,
|
|
126
|
+
reserveMB: this.reserveMB,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stop() {
|
|
131
|
+
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Singleton — shared across the child process
|
|
136
|
+
let _instance = null;
|
|
137
|
+
function getLimiter(opts) {
|
|
138
|
+
if (!_instance) _instance = new AdaptiveLimiter(opts);
|
|
139
|
+
return _instance;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { AdaptiveLimiter, getLimiter };
|