wolverine-ai 4.3.1 → 4.5.0
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 +353 -0
- package/src/brain/brain.js +2 -2
- package/src/core/error-hook.js +40 -0
- package/src/core/error-parser.js +16 -0
- package/src/core/wolverine.js +38 -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.0",
|
|
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": {
|
|
@@ -358,6 +358,90 @@ const TOOL_DEFINITIONS = [
|
|
|
358
358
|
},
|
|
359
359
|
},
|
|
360
360
|
},
|
|
361
|
+
// ── ADVANCED DIAGNOSTICS ──
|
|
362
|
+
{
|
|
363
|
+
type: "function",
|
|
364
|
+
function: {
|
|
365
|
+
name: "verify_node_modules",
|
|
366
|
+
description: "Verify node_modules integrity against package-lock.json. Detects corruption, missing packages, broken bin links.",
|
|
367
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
type: "function",
|
|
372
|
+
function: {
|
|
373
|
+
name: "inspect_certificate",
|
|
374
|
+
description: "Inspect SSL/TLS certificate for a host or local file. Shows expiry, subject, SAN list, chain validity.",
|
|
375
|
+
parameters: {
|
|
376
|
+
type: "object",
|
|
377
|
+
properties: {
|
|
378
|
+
host: { type: "string", description: "Hostname to check (e.g. api.example.com)" },
|
|
379
|
+
port: { type: "number", description: "Port (default: 443)" },
|
|
380
|
+
},
|
|
381
|
+
required: ["host"],
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
type: "function",
|
|
387
|
+
function: {
|
|
388
|
+
name: "inspect_cache",
|
|
389
|
+
description: "Check Redis/cache server health: connectivity, auth, memory usage, connected clients.",
|
|
390
|
+
parameters: {
|
|
391
|
+
type: "object",
|
|
392
|
+
properties: {
|
|
393
|
+
host: { type: "string", description: "Redis host (default: 127.0.0.1)" },
|
|
394
|
+
port: { type: "number", description: "Redis port (default: 6379)" },
|
|
395
|
+
},
|
|
396
|
+
required: [],
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
type: "function",
|
|
402
|
+
function: {
|
|
403
|
+
name: "disk_cleanup",
|
|
404
|
+
description: "Find and optionally clean safe-to-delete files (old backups, caches, logs) to free disk space. Dry run by default.",
|
|
405
|
+
parameters: {
|
|
406
|
+
type: "object",
|
|
407
|
+
properties: {
|
|
408
|
+
dry_run: { type: "boolean", description: "If true (default), only report what would be cleaned" },
|
|
409
|
+
},
|
|
410
|
+
required: [],
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
type: "function",
|
|
416
|
+
function: {
|
|
417
|
+
name: "check_file_descriptors",
|
|
418
|
+
description: "Check open file descriptor count, limits, and identify potential FD leaks (EMFILE prevention).",
|
|
419
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
type: "function",
|
|
424
|
+
function: {
|
|
425
|
+
name: "check_event_loop",
|
|
426
|
+
description: "Scan server code for event loop blocking patterns (readFileSync, execSync, large JSON.parse) and check active handles.",
|
|
427
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
type: "function",
|
|
432
|
+
function: {
|
|
433
|
+
name: "check_websocket",
|
|
434
|
+
description: "Test a WebSocket endpoint by performing a real handshake. Reports connection success, upgrade status, latency.",
|
|
435
|
+
parameters: {
|
|
436
|
+
type: "object",
|
|
437
|
+
properties: {
|
|
438
|
+
url: { type: "string", description: "WebSocket URL (ws:// or wss://)" },
|
|
439
|
+
timeout_ms: { type: "number", description: "Connection timeout (default: 5000)" },
|
|
440
|
+
},
|
|
441
|
+
required: ["url"],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
},
|
|
361
445
|
// ── TASK MANAGEMENT ──
|
|
362
446
|
{
|
|
363
447
|
type: "function",
|
|
@@ -645,6 +729,13 @@ class AgentEngine {
|
|
|
645
729
|
case "restart_service": return this._restartService(args);
|
|
646
730
|
case "check_network": return this._checkNetwork(args);
|
|
647
731
|
case "inspect_env": return this._inspectEnv(args);
|
|
732
|
+
case "verify_node_modules": return this._verifyNodeModules(args);
|
|
733
|
+
case "inspect_certificate": return this._inspectCertificate(args);
|
|
734
|
+
case "inspect_cache": return this._inspectCache(args);
|
|
735
|
+
case "disk_cleanup": return this._diskCleanup(args);
|
|
736
|
+
case "check_file_descriptors": return this._checkFileDescriptors(args);
|
|
737
|
+
case "check_event_loop": return this._checkEventLoop(args);
|
|
738
|
+
case "check_websocket": return this._checkWebsocket(args);
|
|
648
739
|
case "done": return this._done(args);
|
|
649
740
|
// Legacy aliases
|
|
650
741
|
case "list_files": return this._globFiles({ pattern: (args.dir || ".") + "/*" + (args.pattern || "") });
|
|
@@ -1244,6 +1335,268 @@ class AgentEngine {
|
|
|
1244
1335
|
return { content: lines.join("\n") };
|
|
1245
1336
|
}
|
|
1246
1337
|
|
|
1338
|
+
// ── ADVANCED DIAGNOSTICS ──
|
|
1339
|
+
|
|
1340
|
+
_verifyNodeModules() {
|
|
1341
|
+
try {
|
|
1342
|
+
const lockPath = path.join(this.cwd, "package-lock.json");
|
|
1343
|
+
const pkgPath = path.join(this.cwd, "package.json");
|
|
1344
|
+
if (!fs.existsSync(lockPath)) return { content: "No package-lock.json found. Run: npm install" };
|
|
1345
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
1346
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1347
|
+
const missing = [];
|
|
1348
|
+
const broken = [];
|
|
1349
|
+
for (const [name] of Object.entries(deps)) {
|
|
1350
|
+
const modPath = path.join(this.cwd, "node_modules", name);
|
|
1351
|
+
if (!fs.existsSync(modPath)) { missing.push(name); continue; }
|
|
1352
|
+
const modPkg = path.join(modPath, "package.json");
|
|
1353
|
+
if (!fs.existsSync(modPkg)) { broken.push(name + " (no package.json)"); continue; }
|
|
1354
|
+
}
|
|
1355
|
+
// Check .bin links
|
|
1356
|
+
const binDir = path.join(this.cwd, "node_modules", ".bin");
|
|
1357
|
+
let brokenBins = 0;
|
|
1358
|
+
if (fs.existsSync(binDir)) {
|
|
1359
|
+
for (const bin of fs.readdirSync(binDir)) {
|
|
1360
|
+
const target = path.join(binDir, bin);
|
|
1361
|
+
try { fs.readlinkSync(target); } catch { brokenBins++; }
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
const rec = missing.length > 5 || broken.length > 3 ? "rm -rf node_modules && npm install" : missing.length > 0 ? "npm install" : "ok";
|
|
1365
|
+
const lines = [
|
|
1366
|
+
`Dependencies: ${Object.keys(deps).length}`,
|
|
1367
|
+
`Missing from disk: ${missing.length > 0 ? missing.join(", ") : "none"}`,
|
|
1368
|
+
`Broken packages: ${broken.length > 0 ? broken.join(", ") : "none"}`,
|
|
1369
|
+
`Broken .bin links: ${brokenBins}`,
|
|
1370
|
+
`Recommendation: ${rec}`,
|
|
1371
|
+
];
|
|
1372
|
+
return { content: lines.join("\n") };
|
|
1373
|
+
} catch (e) { return { content: `Error: ${e.message}` }; }
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
_inspectCertificate(args) {
|
|
1377
|
+
try {
|
|
1378
|
+
const tls = require("tls");
|
|
1379
|
+
const host = args.host;
|
|
1380
|
+
const port = args.port || 443;
|
|
1381
|
+
return new Promise((resolve) => {
|
|
1382
|
+
const socket = tls.connect({ host, port, servername: host, rejectUnauthorized: false, timeout: 5000 }, () => {
|
|
1383
|
+
const cert = socket.getPeerCertificate();
|
|
1384
|
+
const validTo = new Date(cert.valid_to);
|
|
1385
|
+
const daysLeft = Math.round((validTo - Date.now()) / 86400000);
|
|
1386
|
+
const lines = [
|
|
1387
|
+
`Subject: ${cert.subject?.CN || "unknown"}`,
|
|
1388
|
+
`Issuer: ${cert.issuer?.O || cert.issuer?.CN || "unknown"}`,
|
|
1389
|
+
`Valid: ${cert.valid_from} → ${cert.valid_to}`,
|
|
1390
|
+
`Days until expiry: ${daysLeft}${daysLeft < 30 ? " ⚠️ EXPIRING SOON" : daysLeft < 0 ? " ❌ EXPIRED" : ""}`,
|
|
1391
|
+
`SAN: ${(cert.subjectaltname || "").replace(/DNS:/g, "").split(",").map(s => s.trim()).join(", ") || "none"}`,
|
|
1392
|
+
`Self-signed: ${cert.issuer?.CN === cert.subject?.CN ? "yes" : "no"}`,
|
|
1393
|
+
`Authorized: ${socket.authorized}`,
|
|
1394
|
+
socket.authorizationError ? `TLS error: ${socket.authorizationError}` : "",
|
|
1395
|
+
].filter(Boolean);
|
|
1396
|
+
socket.destroy();
|
|
1397
|
+
resolve({ content: lines.join("\n") });
|
|
1398
|
+
});
|
|
1399
|
+
socket.on("error", (e) => { resolve({ content: `TLS error for ${host}:${port}: ${e.message}` }); });
|
|
1400
|
+
socket.setTimeout(5000, () => { socket.destroy(); resolve({ content: `Timeout connecting to ${host}:${port}` }); });
|
|
1401
|
+
});
|
|
1402
|
+
} catch (e) { return { content: `Error: ${e.message}` }; }
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
_inspectCache(args) {
|
|
1406
|
+
try {
|
|
1407
|
+
const host = args.host || "127.0.0.1";
|
|
1408
|
+
const port = args.port || 6379;
|
|
1409
|
+
const net = require("net");
|
|
1410
|
+
return new Promise((resolve) => {
|
|
1411
|
+
const client = net.createConnection({ host, port, timeout: 3000 }, () => {
|
|
1412
|
+
let buf = "";
|
|
1413
|
+
client.on("data", (d) => { buf += d.toString(); });
|
|
1414
|
+
client.write("PING\r\nINFO memory\r\nINFO clients\r\nINFO keyspace\r\n");
|
|
1415
|
+
setTimeout(() => {
|
|
1416
|
+
client.destroy();
|
|
1417
|
+
const pong = buf.includes("+PONG");
|
|
1418
|
+
const memMatch = buf.match(/used_memory_human:(\S+)/);
|
|
1419
|
+
const clientMatch = buf.match(/connected_clients:(\d+)/);
|
|
1420
|
+
const lines = [
|
|
1421
|
+
`Reachable: ${pong ? "yes" : "no"}`,
|
|
1422
|
+
`Auth required: ${buf.includes("NOAUTH") ? "yes (failed)" : "no"}`,
|
|
1423
|
+
`Memory: ${memMatch ? memMatch[1] : "unknown"}`,
|
|
1424
|
+
`Connected clients: ${clientMatch ? clientMatch[1] : "unknown"}`,
|
|
1425
|
+
];
|
|
1426
|
+
resolve({ content: lines.join("\n") });
|
|
1427
|
+
}, 1500);
|
|
1428
|
+
});
|
|
1429
|
+
client.on("error", (e) => { resolve({ content: `Redis ${host}:${port} error: ${e.message}` }); });
|
|
1430
|
+
client.setTimeout(3000, () => { client.destroy(); resolve({ content: `Redis ${host}:${port} timeout` }); });
|
|
1431
|
+
});
|
|
1432
|
+
} catch (e) { return { content: `Error: ${e.message}` }; }
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
_diskCleanup(args) {
|
|
1436
|
+
try {
|
|
1437
|
+
const dryRun = args.dry_run !== false;
|
|
1438
|
+
const os = require("os");
|
|
1439
|
+
const targets = [];
|
|
1440
|
+
let reclaimable = 0;
|
|
1441
|
+
// Old wolverine backups
|
|
1442
|
+
const backupDir = path.join(os.homedir(), ".wolverine-safe-backups", "snapshots");
|
|
1443
|
+
if (fs.existsSync(backupDir)) {
|
|
1444
|
+
const now = Date.now();
|
|
1445
|
+
for (const dir of fs.readdirSync(backupDir)) {
|
|
1446
|
+
const full = path.join(backupDir, dir);
|
|
1447
|
+
try {
|
|
1448
|
+
const stat = fs.statSync(full);
|
|
1449
|
+
const ageDays = (now - stat.mtimeMs) / 86400000;
|
|
1450
|
+
if (ageDays > 7 && stat.isDirectory()) {
|
|
1451
|
+
let size = 0;
|
|
1452
|
+
const walk = (d) => { for (const f of fs.readdirSync(d)) { const p = path.join(d, f); const s = fs.statSync(p); if (s.isDirectory()) walk(p); else size += s.size; } };
|
|
1453
|
+
walk(full);
|
|
1454
|
+
const mb = Math.round(size / 1048576);
|
|
1455
|
+
targets.push({ path: `~/.wolverine-safe-backups/snapshots/${dir}`, size_mb: mb, reason: `${Math.round(ageDays)}d old backup` });
|
|
1456
|
+
reclaimable += mb;
|
|
1457
|
+
}
|
|
1458
|
+
} catch {}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
// npm cache
|
|
1462
|
+
const cacheDir = path.join(this.cwd, "node_modules", ".cache");
|
|
1463
|
+
if (fs.existsSync(cacheDir)) {
|
|
1464
|
+
try {
|
|
1465
|
+
let size = 0;
|
|
1466
|
+
const walk = (d) => { for (const f of fs.readdirSync(d)) { const p = path.join(d, f); try { const s = fs.statSync(p); if (s.isDirectory()) walk(p); else size += s.size; } catch {} } };
|
|
1467
|
+
walk(cacheDir);
|
|
1468
|
+
const mb = Math.round(size / 1048576);
|
|
1469
|
+
if (mb > 1) { targets.push({ path: "node_modules/.cache/", size_mb: mb, reason: "build cache" }); reclaimable += mb; }
|
|
1470
|
+
} catch {}
|
|
1471
|
+
}
|
|
1472
|
+
// Clean if not dry run
|
|
1473
|
+
if (!dryRun && targets.length > 0) {
|
|
1474
|
+
for (const t of targets) {
|
|
1475
|
+
try { execSync(`rm -rf "${t.path.replace("~", os.homedir())}"`, { timeout: 10000 }); } catch {}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
const diskFree = Math.round(parseInt(execSync("df -m . | tail -1 | awk '{print $4}'", { encoding: "utf-8", cwd: this.cwd, timeout: 3000 }).trim() || "0", 10));
|
|
1479
|
+
const lines = [
|
|
1480
|
+
`Disk free: ${diskFree}MB`,
|
|
1481
|
+
`Reclaimable: ${reclaimable}MB (${targets.length} targets)`,
|
|
1482
|
+
dryRun ? "Mode: DRY RUN (pass dry_run=false to clean)" : `Cleaned ${targets.length} targets`,
|
|
1483
|
+
...targets.map(t => ` ${t.path} (${t.size_mb}MB) — ${t.reason}`),
|
|
1484
|
+
];
|
|
1485
|
+
return { content: lines.join("\n") };
|
|
1486
|
+
} catch (e) { return { content: `Error: ${e.message}` }; }
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
_checkFileDescriptors() {
|
|
1490
|
+
try {
|
|
1491
|
+
if (process.platform === "win32") return { content: "FD check not available on Windows" };
|
|
1492
|
+
const pid = process.pid;
|
|
1493
|
+
const fdDir = `/proc/${pid}/fd`;
|
|
1494
|
+
let count = 0;
|
|
1495
|
+
try { count = fs.readdirSync(fdDir).length; } catch {}
|
|
1496
|
+
let limits = "";
|
|
1497
|
+
try { limits = execSync(`cat /proc/${pid}/limits | grep 'open files'`, { encoding: "utf-8", timeout: 3000 }).trim(); } catch {}
|
|
1498
|
+
const softMatch = limits.match(/(\d+)\s+(\d+)/);
|
|
1499
|
+
const soft = softMatch ? parseInt(softMatch[1]) : 0;
|
|
1500
|
+
const hard = softMatch ? parseInt(softMatch[2]) : 0;
|
|
1501
|
+
const pct = soft > 0 ? Math.round(count / soft * 100) : 0;
|
|
1502
|
+
const lines = [
|
|
1503
|
+
`Open FDs: ${count}`,
|
|
1504
|
+
`Soft limit: ${soft}`,
|
|
1505
|
+
`Hard limit: ${hard}`,
|
|
1506
|
+
`Usage: ${pct}%${pct > 80 ? " ⚠️ HIGH — risk of EMFILE" : pct > 50 ? " — moderate" : " — ok"}`,
|
|
1507
|
+
];
|
|
1508
|
+
// Find top consumers
|
|
1509
|
+
try {
|
|
1510
|
+
const top = execSync(`ls -la /proc/${pid}/fd 2>/dev/null | tail -20 | awk '{print $NF}' | sort | uniq -c | sort -rn | head -10`, { encoding: "utf-8", timeout: 3000 }).trim();
|
|
1511
|
+
if (top) lines.push("Top FD targets:", top);
|
|
1512
|
+
} catch {}
|
|
1513
|
+
return { content: lines.join("\n") };
|
|
1514
|
+
} catch (e) { return { content: `Error: ${e.message}` }; }
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
_checkEventLoop() {
|
|
1518
|
+
try {
|
|
1519
|
+
// Static analysis: find blocking patterns in server code
|
|
1520
|
+
const patterns = [
|
|
1521
|
+
{ regex: /readFileSync\s*\(/g, name: "fs.readFileSync" },
|
|
1522
|
+
{ regex: /writeFileSync\s*\(/g, name: "fs.writeFileSync" },
|
|
1523
|
+
{ regex: /execSync\s*\(/g, name: "child_process.execSync" },
|
|
1524
|
+
{ regex: /pbkdf2Sync\s*\(/g, name: "crypto.pbkdf2Sync" },
|
|
1525
|
+
{ regex: /JSON\.parse\s*\(\s*fs\./g, name: "JSON.parse(fs.read...)" },
|
|
1526
|
+
{ regex: /\.forEach\s*\(\s*(async\s*)?\(/g, name: "forEach (blocks with async)" },
|
|
1527
|
+
];
|
|
1528
|
+
const serverDir = path.join(this.cwd, "server");
|
|
1529
|
+
const findings = [];
|
|
1530
|
+
const walk = (dir) => {
|
|
1531
|
+
if (!fs.existsSync(dir)) return;
|
|
1532
|
+
for (const f of fs.readdirSync(dir)) {
|
|
1533
|
+
if (f === "node_modules" || f.startsWith(".")) continue;
|
|
1534
|
+
const full = path.join(dir, f);
|
|
1535
|
+
const stat = fs.statSync(full);
|
|
1536
|
+
if (stat.isDirectory()) { walk(full); continue; }
|
|
1537
|
+
if (!f.endsWith(".js")) continue;
|
|
1538
|
+
const code = fs.readFileSync(full, "utf-8");
|
|
1539
|
+
for (const p of patterns) {
|
|
1540
|
+
const matches = code.match(p.regex);
|
|
1541
|
+
if (matches) {
|
|
1542
|
+
findings.push(`${path.relative(this.cwd, full)}: ${matches.length}x ${p.name}`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
walk(serverDir);
|
|
1548
|
+
const handles = process._getActiveHandles?.()?.length || "unknown";
|
|
1549
|
+
const requests = process._getActiveRequests?.()?.length || "unknown";
|
|
1550
|
+
const lines = [
|
|
1551
|
+
`Active handles: ${handles}`,
|
|
1552
|
+
`Active requests: ${requests}`,
|
|
1553
|
+
findings.length > 0 ? `\nBlocking patterns found (${findings.length}):` : "No blocking patterns found in server/",
|
|
1554
|
+
...findings.slice(0, 20),
|
|
1555
|
+
];
|
|
1556
|
+
return { content: lines.join("\n") };
|
|
1557
|
+
} catch (e) { return { content: `Error: ${e.message}` }; }
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
_checkWebsocket(args) {
|
|
1561
|
+
try {
|
|
1562
|
+
const url = args.url;
|
|
1563
|
+
const timeout = args.timeout_ms || 5000;
|
|
1564
|
+
const urlObj = new (require("url").URL)(url);
|
|
1565
|
+
const isSecure = urlObj.protocol === "wss:";
|
|
1566
|
+
const client = isSecure ? require("https") : require("http");
|
|
1567
|
+
return new Promise((resolve) => {
|
|
1568
|
+
const req = client.request({
|
|
1569
|
+
hostname: urlObj.hostname,
|
|
1570
|
+
port: urlObj.port || (isSecure ? 443 : 80),
|
|
1571
|
+
path: urlObj.pathname + urlObj.search,
|
|
1572
|
+
method: "GET",
|
|
1573
|
+
headers: {
|
|
1574
|
+
"Upgrade": "websocket",
|
|
1575
|
+
"Connection": "Upgrade",
|
|
1576
|
+
"Sec-WebSocket-Key": require("crypto").randomBytes(16).toString("base64"),
|
|
1577
|
+
"Sec-WebSocket-Version": "13",
|
|
1578
|
+
},
|
|
1579
|
+
timeout,
|
|
1580
|
+
}, (res) => {
|
|
1581
|
+
resolve({ content: `HTTP ${res.statusCode} (expected 101 for WS upgrade)\nHeaders: ${JSON.stringify(res.headers, null, 2).slice(0, 500)}` });
|
|
1582
|
+
res.resume();
|
|
1583
|
+
});
|
|
1584
|
+
req.on("upgrade", (res, socket) => {
|
|
1585
|
+
const lines = [
|
|
1586
|
+
`WebSocket upgrade: SUCCESS (101)`,
|
|
1587
|
+
`Protocol: ${res.headers["sec-websocket-protocol"] || "none"}`,
|
|
1588
|
+
`Latency: connected`,
|
|
1589
|
+
];
|
|
1590
|
+
socket.destroy();
|
|
1591
|
+
resolve({ content: lines.join("\n") });
|
|
1592
|
+
});
|
|
1593
|
+
req.on("error", (e) => { resolve({ content: `WebSocket error for ${url}: ${e.message}` }); });
|
|
1594
|
+
req.on("timeout", () => { req.destroy(); resolve({ content: `WebSocket timeout for ${url} (${timeout}ms)` }); });
|
|
1595
|
+
req.end();
|
|
1596
|
+
});
|
|
1597
|
+
} catch (e) { return { content: `Error: ${e.message}` }; }
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1247
1600
|
_done(args) {
|
|
1248
1601
|
console.log(chalk.green(` ✅ Agent done: ${args.summary}`));
|
|
1249
1602
|
if (this.logger) {
|
package/src/brain/brain.js
CHANGED
|
@@ -66,7 +66,7 @@ const SEED_DOCS = [
|
|
|
66
66
|
metadata: { topic: "verification" },
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
|
-
text: "Wolverine multi-file agent: turn-limited agent loop with
|
|
69
|
+
text: "Wolverine multi-file agent: turn-limited agent loop with 31 tools across 9 categories. Turn budget: simple=4, config=5, complex=8. 90s/call timeout. FILE (7): read_file, write_file, edit_file, glob_files, grep_code, list_dir, move_file. SHELL (3): bash_exec, git_log, git_diff. DATABASE (2): inspect_db, run_db_fix. DIAGNOSTICS (7): check_port, check_env, check_memory, list_processes, check_logs, check_network, inspect_env. SERVER (1): restart_service. DEPS (2): audit_deps, check_migration. RESEARCH (1): web_fetch. ADVANCED (7): verify_node_modules (integrity check — detects corruption, missing pkgs, broken .bin links), inspect_certificate (TLS cert expiry/SAN/chain for SSL errors), inspect_cache (Redis health — PING, memory, clients), disk_cleanup (safe cleanup of old backups/caches for ENOSPC), check_file_descriptors (FD count/limits for EMFILE), check_event_loop (scan for blocking patterns — readFileSync/execSync/pbkdf2Sync), check_websocket (WS handshake test). CONTROL (1): done.",
|
|
70
70
|
metadata: { topic: "agent" },
|
|
71
71
|
},
|
|
72
72
|
{
|
|
@@ -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) {
|
package/src/core/error-parser.js
CHANGED
|
@@ -120,6 +120,22 @@ function classifyError(errorMessage, fullStderr) {
|
|
|
120
120
|
if (/typeerror|referenceerror|rangeerror/.test(msg)) {
|
|
121
121
|
return "runtime";
|
|
122
122
|
}
|
|
123
|
+
// Disk full
|
|
124
|
+
if (/enospc/.test(msg)) {
|
|
125
|
+
return "disk_full";
|
|
126
|
+
}
|
|
127
|
+
// Too many open files
|
|
128
|
+
if (/emfile|enfile/.test(msg)) {
|
|
129
|
+
return "file_descriptors";
|
|
130
|
+
}
|
|
131
|
+
// Connection issues (DB, Redis, external services)
|
|
132
|
+
if (/econnrefused|econnreset|etimedout/.test(msg) && !/eaddrinuse/.test(msg)) {
|
|
133
|
+
return "connection";
|
|
134
|
+
}
|
|
135
|
+
// TLS/SSL certificate errors
|
|
136
|
+
if (/cert_|certificate|self.signed|unable_to_verify/.test(msg)) {
|
|
137
|
+
return "certificate";
|
|
138
|
+
}
|
|
123
139
|
return "unknown";
|
|
124
140
|
}
|
|
125
141
|
|
package/src/core/wolverine.js
CHANGED
|
@@ -654,6 +654,44 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
|
|
|
654
654
|
}
|
|
655
655
|
}
|
|
656
656
|
|
|
657
|
+
// Pattern 5: ENOSPC — disk full, try automated cleanup
|
|
658
|
+
if (/ENOSPC/.test(msg)) {
|
|
659
|
+
try {
|
|
660
|
+
const os = require("os");
|
|
661
|
+
const backupDir = path.join(os.homedir(), ".wolverine-safe-backups", "snapshots");
|
|
662
|
+
let cleaned = 0;
|
|
663
|
+
if (fs.existsSync(backupDir)) {
|
|
664
|
+
const now = Date.now();
|
|
665
|
+
for (const dir of fs.readdirSync(backupDir)) {
|
|
666
|
+
const full = path.join(backupDir, dir);
|
|
667
|
+
try {
|
|
668
|
+
const stat = fs.statSync(full);
|
|
669
|
+
if (stat.isDirectory() && (now - stat.mtimeMs) > 7 * 86400000) {
|
|
670
|
+
execSync(`rm -rf "${full}"`, { timeout: 10000 });
|
|
671
|
+
cleaned++;
|
|
672
|
+
}
|
|
673
|
+
} catch {}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
try { execSync("npm cache clean --force", { cwd, stdio: "pipe", timeout: 30000 }); cleaned++; } catch {}
|
|
677
|
+
if (cleaned > 0) {
|
|
678
|
+
console.log(chalk.blue(` 🧹 Cleaned ${cleaned} items to free disk space`));
|
|
679
|
+
return { fixed: true, action: `Disk full — cleaned ${cleaned} old backups/caches to free space` };
|
|
680
|
+
}
|
|
681
|
+
} catch {}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Pattern 6: EMFILE — too many open files, try raising ulimit
|
|
685
|
+
if (/EMFILE|ENFILE/.test(msg)) {
|
|
686
|
+
try {
|
|
687
|
+
if (process.platform !== "win32") {
|
|
688
|
+
execSync("ulimit -n 65536 2>/dev/null || true", { cwd, stdio: "pipe", timeout: 3000 });
|
|
689
|
+
console.log(chalk.blue(" 📂 Raised file descriptor limit to 65536"));
|
|
690
|
+
return { fixed: true, action: "EMFILE — raised file descriptor limit (ulimit -n 65536)" };
|
|
691
|
+
}
|
|
692
|
+
} catch {}
|
|
693
|
+
}
|
|
694
|
+
|
|
657
695
|
return { fixed: false };
|
|
658
696
|
}
|
|
659
697
|
|
|
@@ -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 };
|