wolverine-ai 1.5.2 → 1.6.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 +1 -1
- package/src/core/ai-client.js +2 -2
- package/src/core/runner.js +25 -3
- package/src/core/verifier.js +118 -16
- package/src/core/wolverine.js +54 -5
- package/src/monitor/error-monitor.js +17 -5
- package/src/security/rate-limiter.js +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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": {
|
|
@@ -720,7 +720,7 @@ Project root: ${this.cwd}${primaryFile ? `\nPrimary crash file: ${primaryFile}`
|
|
|
720
720
|
}
|
|
721
721
|
}
|
|
722
722
|
|
|
723
|
-
const timeout = Math.min(args.timeout ||
|
|
723
|
+
const timeout = Math.min(args.timeout || 30000, 60000);
|
|
724
724
|
try {
|
|
725
725
|
const output = execSync(args.command, {
|
|
726
726
|
cwd: this.cwd,
|
package/src/core/ai-client.js
CHANGED
|
@@ -335,7 +335,7 @@ async function _chatCallWithHistory(openai, { model, messages, tools, maxTokens
|
|
|
335
335
|
* Send an error context to OpenAI and get a repair patch back.
|
|
336
336
|
* Uses CODING_MODEL — routes to correct API automatically.
|
|
337
337
|
*/
|
|
338
|
-
async function requestRepair({ filePath, sourceCode, backupSourceCode, errorMessage, stackTrace }) {
|
|
338
|
+
async function requestRepair({ filePath, sourceCode, backupSourceCode, errorMessage, stackTrace, extraContext }) {
|
|
339
339
|
const model = getModel("coding");
|
|
340
340
|
|
|
341
341
|
const systemPrompt = "You are a Node.js debugging expert. Respond with ONLY valid JSON, no markdown fences.";
|
|
@@ -357,7 +357,7 @@ ${errorMessage}
|
|
|
357
357
|
${stackTrace}
|
|
358
358
|
\`\`\`
|
|
359
359
|
|
|
360
|
-
${backupSourceCode ? `## Last Known Working Version\n\`\`\`javascript\n${backupSourceCode}\n\`\`\`\n\nCompare the current broken code with this working version. If the broken code added something that doesn't work, REVERT that addition rather than patching around it.\n` : ""}## Instructions
|
|
360
|
+
${backupSourceCode ? `## Last Known Working Version\n\`\`\`javascript\n${backupSourceCode}\n\`\`\`\n\nCompare the current broken code with this working version. If the broken code added something that doesn't work, REVERT that addition rather than patching around it.\n` : ""}${extraContext || ""}## Instructions
|
|
361
361
|
1. Identify the root cause of the error.
|
|
362
362
|
2. Not all errors are code bugs. Choose the correct fix type:
|
|
363
363
|
- "Cannot find module 'X'" (not starting with ./ or ../) = missing npm package → use "commands" to npm install
|
package/src/core/runner.js
CHANGED
|
@@ -50,6 +50,8 @@ class WolverineRunner {
|
|
|
50
50
|
windowMs: parseInt(process.env.WOLVERINE_RATE_WINDOW_MS, 10) || 600000,
|
|
51
51
|
minGapMs: parseInt(process.env.WOLVERINE_RATE_MIN_GAP_MS, 10) || 5000,
|
|
52
52
|
maxTokensPerHour: parseInt(process.env.WOLVERINE_RATE_MAX_TOKENS_HOUR, 10) || 100000,
|
|
53
|
+
maxGlobalHealsPerWindow: parseInt(process.env.WOLVERINE_RATE_MAX_GLOBAL_HEALS, 10) || 5,
|
|
54
|
+
globalWindowMs: parseInt(process.env.WOLVERINE_RATE_GLOBAL_WINDOW_MS, 10) || 300000,
|
|
53
55
|
});
|
|
54
56
|
this.backupManager = new BackupManager(this.cwd);
|
|
55
57
|
this.logger = new EventLogger(this.cwd);
|
|
@@ -337,13 +339,32 @@ class WolverineRunner {
|
|
|
337
339
|
// Start health monitoring
|
|
338
340
|
this.healthMonitor.stop();
|
|
339
341
|
this.healthMonitor.reset();
|
|
340
|
-
this.healthMonitor.start((reason) => {
|
|
341
|
-
if (this._healInProgress) return;
|
|
342
|
-
console.log(chalk.red(`\n🚨 Health check triggered
|
|
342
|
+
this.healthMonitor.start(async (reason) => {
|
|
343
|
+
if (this._healInProgress || !this.running) return;
|
|
344
|
+
console.log(chalk.red(`\n🚨 Health check triggered heal (reason: ${reason})`));
|
|
343
345
|
this.logger.error(EVENT_TYPES.HEALTH_UNRESPONSIVE, `Server unresponsive: ${reason}`, { reason });
|
|
346
|
+
this.healthMonitor.stop();
|
|
347
|
+
|
|
348
|
+
// Kill the hung process — remove exit listener to prevent double-heal
|
|
344
349
|
if (this.child) {
|
|
350
|
+
this.child.removeAllListeners("exit");
|
|
345
351
|
this.child.kill("SIGKILL");
|
|
352
|
+
this.child = null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Synthesize error context for the heal pipeline
|
|
356
|
+
this._stderrBuffer = `Server became unresponsive. Health check failed: ${reason}\n` +
|
|
357
|
+
`The server was running but stopped responding to HTTP requests.\n` +
|
|
358
|
+
`Possible causes: infinite loop, deadlock, memory exhaustion, blocked event loop.`;
|
|
359
|
+
|
|
360
|
+
this.retryCount++;
|
|
361
|
+
if (this.retryCount > this.maxRetries) {
|
|
362
|
+
console.log(chalk.red(`\n🛑 Max retries reached.`));
|
|
363
|
+
this._logRollbackHint();
|
|
364
|
+
this.running = false;
|
|
365
|
+
return;
|
|
346
366
|
}
|
|
367
|
+
await this._healAndRestart();
|
|
347
368
|
});
|
|
348
369
|
|
|
349
370
|
this.child.on("exit", async (code, signal) => {
|
|
@@ -514,6 +535,7 @@ class WolverineRunner {
|
|
|
514
535
|
mcp: this.mcp,
|
|
515
536
|
skills: this.skills,
|
|
516
537
|
repairHistory: this.repairHistory,
|
|
538
|
+
routeContext: { path: routePath, method: errorDetails?.method },
|
|
517
539
|
});
|
|
518
540
|
|
|
519
541
|
if (result.healed) {
|
package/src/core/verifier.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { spawn } = require("child_process");
|
|
2
2
|
const chalk = require("chalk");
|
|
3
|
+
const { parseError, classifyError } = require("./error-parser");
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Fix Verifier — validates that a patch actually fixes the error
|
|
@@ -75,9 +76,18 @@ function bootProbe(scriptPath, cwd, originalErrorSignature) {
|
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
// Check if it's the same error
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// Check if it's the same error — use classification, not string matching
|
|
80
|
+
let sameError = false;
|
|
81
|
+
if (originalErrorSignature) {
|
|
82
|
+
const newParsed = parseError(stderr);
|
|
83
|
+
const origParts = (originalErrorSignature || "").split("::");
|
|
84
|
+
const origMsg = origParts.slice(1).join("::").trim();
|
|
85
|
+
const origType = classifyError(origMsg, "");
|
|
86
|
+
const origClass = (origMsg.match(/^(\w*Error)/) || [])[1] || "";
|
|
87
|
+
const newClass = (newParsed.errorMessage?.match(/^(\w*Error)/) || [])[1] || "";
|
|
88
|
+
// Same error = same classification type AND same error class (TypeError vs ReferenceError)
|
|
89
|
+
sameError = newParsed.errorType === origType && origClass === newClass;
|
|
90
|
+
}
|
|
81
91
|
|
|
82
92
|
resolve({
|
|
83
93
|
status: "crashed",
|
|
@@ -112,11 +122,20 @@ function bootProbe(scriptPath, cwd, originalErrorSignature) {
|
|
|
112
122
|
* - { verified: false, status: "new-error" } — different error, fix broke something else → rollback
|
|
113
123
|
* - { verified: false, status: "syntax-error" } — introduced syntax error → rollback
|
|
114
124
|
*/
|
|
115
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Full verification pipeline.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} scriptPath — entry point to verify
|
|
129
|
+
* @param {string} cwd — working directory
|
|
130
|
+
* @param {string} originalErrorSignature — error signature for same-error detection
|
|
131
|
+
* @param {object} routeContext — optional { path, method } for route-level testing
|
|
132
|
+
*/
|
|
133
|
+
async function verifyFix(scriptPath, cwd, originalErrorSignature, routeContext) {
|
|
134
|
+
const steps = routeContext?.path ? 3 : 2;
|
|
116
135
|
console.log(chalk.yellow("\n🔬 Verifying fix...\n"));
|
|
117
136
|
|
|
118
137
|
// Step 1: Syntax check
|
|
119
|
-
console.log(chalk.gray(
|
|
138
|
+
console.log(chalk.gray(` [1/${steps}] Syntax check...`));
|
|
120
139
|
const syntax = await syntaxCheck(scriptPath);
|
|
121
140
|
if (!syntax.valid) {
|
|
122
141
|
console.log(chalk.red(` ❌ Syntax error introduced by fix:\n ${syntax.error}`));
|
|
@@ -125,22 +144,105 @@ async function verifyFix(scriptPath, cwd, originalErrorSignature) {
|
|
|
125
144
|
console.log(chalk.green(" ✅ Syntax OK"));
|
|
126
145
|
|
|
127
146
|
// Step 2: Boot probe
|
|
128
|
-
console.log(chalk.gray(
|
|
147
|
+
console.log(chalk.gray(` [2/${steps}] Boot probe (watching for crashes)...`));
|
|
129
148
|
const probe = await bootProbe(scriptPath, cwd, originalErrorSignature);
|
|
130
149
|
|
|
131
|
-
if (probe.status
|
|
132
|
-
|
|
133
|
-
|
|
150
|
+
if (probe.status !== "alive") {
|
|
151
|
+
if (probe.sameError) {
|
|
152
|
+
console.log(chalk.red(" ❌ Same error occurred — fix did not resolve the issue."));
|
|
153
|
+
return { verified: false, status: "same-error", stderr: probe.stderr };
|
|
154
|
+
}
|
|
155
|
+
console.log(chalk.red(" ❌ A different error occurred — fix may have introduced a new bug."));
|
|
156
|
+
return { verified: false, status: "new-error", stderr: probe.stderr };
|
|
134
157
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if
|
|
138
|
-
|
|
139
|
-
|
|
158
|
+
console.log(chalk.green(" ✅ Process booted successfully"));
|
|
159
|
+
|
|
160
|
+
// Step 3: Route probe (if we know which route was failing)
|
|
161
|
+
if (routeContext?.path) {
|
|
162
|
+
console.log(chalk.gray(` [3/${steps}] Route probe: ${routeContext.method || "GET"} ${routeContext.path}...`));
|
|
163
|
+
const routeResult = await routeProbe(scriptPath, cwd, routeContext);
|
|
164
|
+
if (routeResult.status === "failed") {
|
|
165
|
+
console.log(chalk.red(` ❌ Route ${routeContext.path} still fails (HTTP ${routeResult.statusCode}): ${routeResult.body?.slice(0, 80)}`));
|
|
166
|
+
return { verified: false, status: "route-still-broken", stderr: routeResult.body };
|
|
167
|
+
}
|
|
168
|
+
if (routeResult.status === "passed") {
|
|
169
|
+
console.log(chalk.green(` ✅ Route ${routeContext.path} responds OK (HTTP ${routeResult.statusCode})`));
|
|
170
|
+
} else {
|
|
171
|
+
console.log(chalk.gray(` ⚠️ Route probe skipped: ${routeResult.reason || "unknown"}`));
|
|
172
|
+
}
|
|
140
173
|
}
|
|
141
174
|
|
|
142
|
-
|
|
143
|
-
|
|
175
|
+
return { verified: true, status: "fixed" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Route probe — boot the server on PORT=0, detect the actual port from stdout,
|
|
180
|
+
* then HTTP-test the failing route.
|
|
181
|
+
*/
|
|
182
|
+
function routeProbe(scriptPath, cwd, routeContext) {
|
|
183
|
+
const http = require("http");
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
let stdout = "";
|
|
186
|
+
let stderr = "";
|
|
187
|
+
let settled = false;
|
|
188
|
+
|
|
189
|
+
const probeEnv = { ...process.env, PORT: "0", WOLVERINE_PROBE: "1" };
|
|
190
|
+
const child = spawn("node", [scriptPath], {
|
|
191
|
+
cwd, env: probeEnv,
|
|
192
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
196
|
+
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
197
|
+
|
|
198
|
+
child.on("exit", () => {
|
|
199
|
+
if (settled) return;
|
|
200
|
+
settled = true;
|
|
201
|
+
resolve({ status: "failed", statusCode: 0, body: stderr || "Process exited before route test" });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Poll stdout for port announcement
|
|
205
|
+
const checkPort = setInterval(() => {
|
|
206
|
+
if (settled) { clearInterval(checkPort); return; }
|
|
207
|
+
const m = stdout.match(/(?:listening|running|started|on)\s+(?:on\s+)?(?:(?:https?:\/\/)?[\w.]+:)?(\d{4,5})/i)
|
|
208
|
+
|| stdout.match(/:(\d{4,5})/);
|
|
209
|
+
if (m) {
|
|
210
|
+
clearInterval(checkPort);
|
|
211
|
+
const port = parseInt(m[1], 10);
|
|
212
|
+
// Test the route
|
|
213
|
+
const req = http.request({
|
|
214
|
+
hostname: "127.0.0.1", port,
|
|
215
|
+
path: routeContext.path,
|
|
216
|
+
method: routeContext.method || "GET",
|
|
217
|
+
timeout: 5000,
|
|
218
|
+
}, (res) => {
|
|
219
|
+
let body = "";
|
|
220
|
+
res.on("data", (c) => { body += c; });
|
|
221
|
+
res.on("end", () => {
|
|
222
|
+
settled = true;
|
|
223
|
+
child.kill("SIGTERM");
|
|
224
|
+
if (res.statusCode < 500) {
|
|
225
|
+
resolve({ status: "passed", statusCode: res.statusCode });
|
|
226
|
+
} else {
|
|
227
|
+
resolve({ status: "failed", statusCode: res.statusCode, body: body.slice(0, 500) });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
req.on("error", (e) => { settled = true; child.kill("SIGTERM"); resolve({ status: "failed", statusCode: 0, body: e.message }); });
|
|
232
|
+
req.on("timeout", () => { req.destroy(); settled = true; child.kill("SIGTERM"); resolve({ status: "failed", statusCode: 0, body: "timeout" }); });
|
|
233
|
+
req.end();
|
|
234
|
+
}
|
|
235
|
+
}, 300);
|
|
236
|
+
|
|
237
|
+
// Overall timeout
|
|
238
|
+
setTimeout(() => {
|
|
239
|
+
clearInterval(checkPort);
|
|
240
|
+
if (settled) return;
|
|
241
|
+
settled = true;
|
|
242
|
+
child.kill("SIGTERM");
|
|
243
|
+
resolve({ status: "skipped", reason: "Could not detect server port from stdout" });
|
|
244
|
+
}, BOOT_PROBE_TIMEOUT_MS + 5000);
|
|
245
|
+
});
|
|
144
246
|
}
|
|
145
247
|
|
|
146
248
|
module.exports = { verifyFix, syntaxCheck, bootProbe, BOOT_PROBE_TIMEOUT_MS };
|
package/src/core/wolverine.js
CHANGED
|
@@ -23,7 +23,24 @@ const { EVENT_TYPES } = require("../logger/event-logger");
|
|
|
23
23
|
*
|
|
24
24
|
* The engine tries fast path first. If that fails verification, it escalates to the agent.
|
|
25
25
|
*/
|
|
26
|
-
async function heal(
|
|
26
|
+
async function heal(opts) {
|
|
27
|
+
const HEAL_TIMEOUT_MS = parseInt(process.env.WOLVERINE_HEAL_TIMEOUT_MS, 10) || 300000; // 5 min
|
|
28
|
+
try {
|
|
29
|
+
return await Promise.race([
|
|
30
|
+
_healImpl(opts),
|
|
31
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), HEAL_TIMEOUT_MS)),
|
|
32
|
+
]);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err.message === "timeout") {
|
|
35
|
+
console.log(chalk.red(`\n🐺 Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s`));
|
|
36
|
+
if (opts.logger) opts.logger.error(EVENT_TYPES.HEAL_FAILED, `Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s`);
|
|
37
|
+
return { healed: false, explanation: `Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s` };
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager, logger, brain, mcp, skills, repairHistory, routeContext }) {
|
|
27
44
|
const healStartTime = Date.now();
|
|
28
45
|
const { redact, hasSecrets } = require("../security/secret-redactor");
|
|
29
46
|
|
|
@@ -70,6 +87,16 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
70
87
|
console.log(chalk.cyan(` Error: ${parsed.errorMessage}`));
|
|
71
88
|
console.log(chalk.cyan(` Type: ${parsed.errorType || "unknown"}`));
|
|
72
89
|
|
|
90
|
+
// 2c. If error mentions env vars, collect env context for AI
|
|
91
|
+
let envContext = "";
|
|
92
|
+
if (/process\.env|\.env|missing.*(?:key|token|secret|api|url|host|port|password|database)|undefined.*(?:config|setting)/i.test(parsed.errorMessage + " " + (parsed.stackTrace || ""))) {
|
|
93
|
+
const envKeys = Object.keys(process.env)
|
|
94
|
+
.filter(k => !k.startsWith("npm_") && !k.startsWith("WOLVERINE_") && !k.startsWith("__"))
|
|
95
|
+
.sort();
|
|
96
|
+
envContext = `\nAvailable environment variables (names only, values redacted): ${envKeys.join(", ")}\nIf the error is about a missing env var, suggest setting it rather than working around it in code.\n`;
|
|
97
|
+
console.log(chalk.gray(` 🔑 Env context: ${envKeys.length} vars available`));
|
|
98
|
+
}
|
|
99
|
+
|
|
73
100
|
// 3. Rate limit check
|
|
74
101
|
const rateCheck = rateLimiter.check(errorSignature);
|
|
75
102
|
if (!rateCheck.allowed) {
|
|
@@ -200,7 +227,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
200
227
|
backupManager.setErrorSignature(bid, errorSignature);
|
|
201
228
|
if (logger) logger.info(EVENT_TYPES.BACKUP_CREATED, `Backup ${bid} (iteration ${iteration})`, { backupId: bid });
|
|
202
229
|
|
|
203
|
-
const fullContext = [brainContext, researchContext, researchCtx].filter(Boolean).join("\n");
|
|
230
|
+
const fullContext = [brainContext, researchContext, researchCtx, envContext].filter(Boolean).join("\n");
|
|
204
231
|
|
|
205
232
|
let result;
|
|
206
233
|
if (iteration === 1 && hasFile) {
|
|
@@ -211,6 +238,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
211
238
|
const repair = await requestRepair({
|
|
212
239
|
filePath: parsed.filePath, sourceCode, backupSourceCode,
|
|
213
240
|
errorMessage: parsed.errorMessage, stackTrace: parsed.stackTrace,
|
|
241
|
+
extraContext: envContext,
|
|
214
242
|
});
|
|
215
243
|
rateLimiter.record(errorSignature);
|
|
216
244
|
|
|
@@ -244,7 +272,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
244
272
|
for (const r of patchResults) console.log(chalk.green(` ✅ Patched: ${r.file}`));
|
|
245
273
|
}
|
|
246
274
|
|
|
247
|
-
const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
|
|
275
|
+
const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
|
|
248
276
|
if (verification.verified) {
|
|
249
277
|
backupManager.markVerified(bid);
|
|
250
278
|
rateLimiter.clearSignature(errorSignature);
|
|
@@ -276,7 +304,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
276
304
|
if (agentResult.success) {
|
|
277
305
|
// Verify: if we have a file, do syntax + boot check. Otherwise just boot probe.
|
|
278
306
|
if (hasFile) {
|
|
279
|
-
const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
|
|
307
|
+
const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
|
|
280
308
|
if (verification.verified) {
|
|
281
309
|
backupManager.markVerified(bid);
|
|
282
310
|
rateLimiter.clearSignature(errorSignature);
|
|
@@ -304,7 +332,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
304
332
|
|
|
305
333
|
if (subResult.success) {
|
|
306
334
|
if (hasFile) {
|
|
307
|
-
const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
|
|
335
|
+
const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
|
|
308
336
|
if (verification.verified) {
|
|
309
337
|
backupManager.markVerified(bid);
|
|
310
338
|
rateLimiter.clearSignature(errorSignature);
|
|
@@ -439,6 +467,27 @@ async function tryOperationalFix(parsed, cwd, logger) {
|
|
|
439
467
|
}
|
|
440
468
|
}
|
|
441
469
|
|
|
470
|
+
// Pattern 4: EADDRINUSE — port taken by stale process
|
|
471
|
+
if (/EADDRINUSE/.test(msg)) {
|
|
472
|
+
const portMatch = msg.match(/:(\d{2,5})/) || msg.match(/port\s+(\d{2,5})/i);
|
|
473
|
+
if (portMatch) {
|
|
474
|
+
const port = parseInt(portMatch[1], 10);
|
|
475
|
+
try {
|
|
476
|
+
if (process.platform === "win32") {
|
|
477
|
+
const out = execSync(`netstat -ano | findstr ":${port}" | findstr "LISTENING"`, { encoding: "utf-8", timeout: 3000 }).trim();
|
|
478
|
+
const pids = [...new Set(out.split("\n").map(l => parseInt(l.trim().split(/\s+/).pop(), 10)).filter(p => p && p !== process.pid))];
|
|
479
|
+
for (const pid of pids) { try { execSync(`taskkill /PID ${pid} /F`, { timeout: 3000 }); } catch {} }
|
|
480
|
+
if (pids.length > 0) return { fixed: true, action: `Killed stale process(es) on port ${port}: PIDs ${pids.join(", ")}` };
|
|
481
|
+
} else {
|
|
482
|
+
const out = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf-8", timeout: 3000 }).trim();
|
|
483
|
+
const pids = out.split("\n").map(p => parseInt(p, 10)).filter(p => p && p !== process.pid);
|
|
484
|
+
for (const pid of pids) { try { process.kill(pid, "SIGKILL"); } catch {} }
|
|
485
|
+
if (pids.length > 0) return { fixed: true, action: `Killed stale process(es) on port ${port}: PIDs ${pids.join(", ")}` };
|
|
486
|
+
}
|
|
487
|
+
} catch { /* no stale process found */ }
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
442
491
|
return { fixed: false };
|
|
443
492
|
}
|
|
444
493
|
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
const chalk = require("chalk");
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Normalize a route path by replacing dynamic segments with :id placeholders.
|
|
5
|
+
* /api/users/123 → /api/users/:id
|
|
6
|
+
* /api/orders/abc-def-ghi → /api/orders/:id
|
|
7
|
+
*/
|
|
8
|
+
function normalizeRoute(routePath) {
|
|
9
|
+
if (!routePath) return routePath;
|
|
10
|
+
const pathOnly = routePath.split("?")[0];
|
|
11
|
+
return pathOnly
|
|
12
|
+
.replace(/\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{24}|[0-9]+)/gi, "/:id")
|
|
13
|
+
.replace(/\/+$/, "") || "/";
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
/**
|
|
4
17
|
* Error Monitor — detects caught 500 errors that don't crash the process.
|
|
5
18
|
*
|
|
@@ -34,11 +47,9 @@ class ErrorMonitor {
|
|
|
34
47
|
* @param {object} errorDetails — { message, stack, file, line }
|
|
35
48
|
*/
|
|
36
49
|
record(routePath, statusCode, errorDetails) {
|
|
50
|
+
routePath = normalizeRoute(routePath);
|
|
37
51
|
if (statusCode < 500) {
|
|
38
|
-
|
|
39
|
-
if (this.routes.has(routePath)) {
|
|
40
|
-
this.routes.delete(routePath);
|
|
41
|
-
}
|
|
52
|
+
if (this.routes.has(routePath)) this.routes.delete(routePath);
|
|
42
53
|
return;
|
|
43
54
|
}
|
|
44
55
|
|
|
@@ -89,6 +100,7 @@ class ErrorMonitor {
|
|
|
89
100
|
* Clear a route's error state (e.g., after a successful heal).
|
|
90
101
|
*/
|
|
91
102
|
clearRoute(routePath) {
|
|
103
|
+
routePath = normalizeRoute(routePath);
|
|
92
104
|
this.routes.delete(routePath);
|
|
93
105
|
this._cooldowns.delete(routePath);
|
|
94
106
|
}
|
|
@@ -118,4 +130,4 @@ class ErrorMonitor {
|
|
|
118
130
|
}
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
module.exports = { ErrorMonitor };
|
|
133
|
+
module.exports = { ErrorMonitor, normalizeRoute };
|
|
@@ -18,11 +18,17 @@ class RateLimiter {
|
|
|
18
18
|
// Max cost per hour in estimated tokens (rough budget protection)
|
|
19
19
|
this.maxTokensPerHour = options.maxTokensPerHour || 100000;
|
|
20
20
|
|
|
21
|
+
// Global heal cap — stops infinite heal loops regardless of error signature
|
|
22
|
+
this.maxGlobalHealsPerWindow = options.maxGlobalHealsPerWindow || 5;
|
|
23
|
+
this.globalWindowMs = options.globalWindowMs || 300000; // 5 minutes
|
|
24
|
+
|
|
21
25
|
// Internal state
|
|
22
26
|
this._callLog = []; // timestamps of recent calls
|
|
23
27
|
this._tokenLog = []; // { timestamp, tokens } for budget tracking
|
|
24
28
|
this._errorSignatures = {}; // signature -> { count, lastSeen, backoffMs }
|
|
25
29
|
this._lastCallTime = 0;
|
|
30
|
+
this._globalHealCount = 0;
|
|
31
|
+
this._globalHealWindowStart = Date.now();
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
/**
|
|
@@ -33,6 +39,21 @@ class RateLimiter {
|
|
|
33
39
|
const now = Date.now();
|
|
34
40
|
this._pruneOldEntries(now);
|
|
35
41
|
|
|
42
|
+
// 0. Global heal cap — regardless of error signature
|
|
43
|
+
const globalElapsed = now - this._globalHealWindowStart;
|
|
44
|
+
if (globalElapsed > this.globalWindowMs) {
|
|
45
|
+
this._globalHealCount = 0;
|
|
46
|
+
this._globalHealWindowStart = now;
|
|
47
|
+
}
|
|
48
|
+
if (this._globalHealCount >= this.maxGlobalHealsPerWindow) {
|
|
49
|
+
const waitMs = this.globalWindowMs - globalElapsed;
|
|
50
|
+
return {
|
|
51
|
+
allowed: false,
|
|
52
|
+
reason: `Global heal limit: ${this.maxGlobalHealsPerWindow} heals in ${Math.round(this.globalWindowMs / 1000)}s. Wait ${Math.ceil(waitMs / 1000)}s.`,
|
|
53
|
+
waitMs: Math.max(waitMs, 1000),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
// 1. Minimum gap between calls
|
|
37
58
|
const sinceLast = now - this._lastCallTime;
|
|
38
59
|
if (sinceLast < this.minGapMs) {
|
|
@@ -83,6 +104,7 @@ class RateLimiter {
|
|
|
83
104
|
this._callLog.push(now);
|
|
84
105
|
this._tokenLog.push({ timestamp: now, tokens: estimatedTokens });
|
|
85
106
|
this._lastCallTime = now;
|
|
107
|
+
this._globalHealCount++;
|
|
86
108
|
|
|
87
109
|
// Track error signature for loop detection
|
|
88
110
|
if (errorSignature) {
|
|
@@ -125,6 +147,8 @@ class RateLimiter {
|
|
|
125
147
|
return {
|
|
126
148
|
callsInWindow: this._callLog.length,
|
|
127
149
|
maxCallsPerWindow: this.maxCallsPerWindow,
|
|
150
|
+
globalHealsInWindow: this._globalHealCount,
|
|
151
|
+
maxGlobalHealsPerWindow: this.maxGlobalHealsPerWindow,
|
|
128
152
|
trackedErrorSignatures: Object.keys(this._errorSignatures).length,
|
|
129
153
|
estimatedTokensLastHour: this._tokenLog
|
|
130
154
|
.filter(e => e.timestamp > now - 3600000)
|