wolverine-ai 1.0.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/PLATFORM.md +442 -0
- package/README.md +475 -0
- package/SERVER_BEST_PRACTICES.md +62 -0
- package/TELEMETRY.md +108 -0
- package/bin/wolverine.js +95 -0
- package/examples/01-basic-typo.js +31 -0
- package/examples/02-multi-file/routes/users.js +15 -0
- package/examples/02-multi-file/server.js +25 -0
- package/examples/03-syntax-error.js +23 -0
- package/examples/04-secret-leak.js +14 -0
- package/examples/05-expired-key.js +27 -0
- package/examples/06-json-config/config.json +13 -0
- package/examples/06-json-config/server.js +28 -0
- package/examples/07-rate-limit-loop.js +11 -0
- package/examples/08-sandbox-escape.js +20 -0
- package/examples/buggy-server.js +39 -0
- package/examples/demos/01-basic-typo/index.js +20 -0
- package/examples/demos/01-basic-typo/routes/api.js +13 -0
- package/examples/demos/01-basic-typo/routes/health.js +4 -0
- package/examples/demos/02-multi-file/index.js +24 -0
- package/examples/demos/02-multi-file/routes/api.js +13 -0
- package/examples/demos/02-multi-file/routes/health.js +4 -0
- package/examples/demos/03-syntax-error/index.js +18 -0
- package/examples/demos/04-secret-leak/index.js +16 -0
- package/examples/demos/05-expired-key/index.js +21 -0
- package/examples/demos/06-json-config/config.json +9 -0
- package/examples/demos/06-json-config/index.js +20 -0
- package/examples/demos/07-null-crash/index.js +16 -0
- package/examples/run-demo.js +110 -0
- package/package.json +67 -0
- package/server/config/settings.json +62 -0
- package/server/index.js +33 -0
- package/server/routes/api.js +12 -0
- package/server/routes/health.js +16 -0
- package/server/routes/time.js +12 -0
- package/src/agent/agent-engine.js +727 -0
- package/src/agent/goal-loop.js +140 -0
- package/src/agent/research-agent.js +120 -0
- package/src/agent/sub-agents.js +176 -0
- package/src/backup/backup-manager.js +321 -0
- package/src/brain/brain.js +315 -0
- package/src/brain/embedder.js +131 -0
- package/src/brain/function-map.js +263 -0
- package/src/brain/vector-store.js +267 -0
- package/src/core/ai-client.js +387 -0
- package/src/core/cluster-manager.js +144 -0
- package/src/core/config.js +89 -0
- package/src/core/error-parser.js +87 -0
- package/src/core/health-monitor.js +129 -0
- package/src/core/models.js +132 -0
- package/src/core/patcher.js +55 -0
- package/src/core/runner.js +464 -0
- package/src/core/system-info.js +141 -0
- package/src/core/verifier.js +146 -0
- package/src/core/wolverine.js +290 -0
- package/src/dashboard/server.js +1332 -0
- package/src/index.js +94 -0
- package/src/logger/event-logger.js +237 -0
- package/src/logger/pricing.js +96 -0
- package/src/logger/repair-history.js +109 -0
- package/src/logger/token-tracker.js +277 -0
- package/src/mcp/mcp-client.js +224 -0
- package/src/mcp/mcp-registry.js +228 -0
- package/src/mcp/mcp-security.js +152 -0
- package/src/monitor/perf-monitor.js +300 -0
- package/src/monitor/process-monitor.js +231 -0
- package/src/monitor/route-prober.js +191 -0
- package/src/notifications/notifier.js +227 -0
- package/src/platform/heartbeat.js +93 -0
- package/src/platform/queue.js +53 -0
- package/src/platform/register.js +64 -0
- package/src/platform/telemetry.js +76 -0
- package/src/security/admin-auth.js +150 -0
- package/src/security/injection-detector.js +174 -0
- package/src/security/rate-limiter.js +152 -0
- package/src/security/sandbox.js +128 -0
- package/src/security/secret-redactor.js +217 -0
- package/src/skills/skill-registry.js +129 -0
- package/src/skills/sql.js +375 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
const { execSync } = require("child_process");
|
|
2
|
+
const chalk = require("chalk");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Process Monitor — heartbeat, memory leak detection, frozen state detection.
|
|
6
|
+
*
|
|
7
|
+
* Tracks the child process health:
|
|
8
|
+
* - Memory usage (RSS, heap) with leak detection
|
|
9
|
+
* - CPU usage percentage
|
|
10
|
+
* - Heartbeat (is the process alive?)
|
|
11
|
+
* - Frozen detection (process alive but not responding)
|
|
12
|
+
*
|
|
13
|
+
* Triggers restart when:
|
|
14
|
+
* - Memory exceeds threshold (configurable, default 512MB)
|
|
15
|
+
* - Memory growing consistently for N samples (leak detection)
|
|
16
|
+
* - Process not responding to health checks (frozen)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
class ProcessMonitor {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.logger = options.logger;
|
|
22
|
+
this.pid = null;
|
|
23
|
+
|
|
24
|
+
// Thresholds
|
|
25
|
+
this.maxMemoryMB = options.maxMemoryMB || parseInt(process.env.WOLVERINE_MAX_MEMORY_MB, 10) || 512;
|
|
26
|
+
this.leakSamples = options.leakSamples || 10; // consecutive growing samples = leak
|
|
27
|
+
this.sampleIntervalMs = options.sampleIntervalMs || 10000; // sample every 10s
|
|
28
|
+
|
|
29
|
+
// State
|
|
30
|
+
this._samples = []; // { timestamp, rss, heap, cpu }
|
|
31
|
+
this._timer = null;
|
|
32
|
+
this._running = false;
|
|
33
|
+
this._onRestart = null;
|
|
34
|
+
this._consecutiveGrowth = 0;
|
|
35
|
+
|
|
36
|
+
// Analytics aggregates
|
|
37
|
+
this._peakMemory = 0;
|
|
38
|
+
this._avgMemory = 0;
|
|
39
|
+
this._sampleCount = 0;
|
|
40
|
+
this._cpuSamples = [];
|
|
41
|
+
this._lastCpuUsage = null;
|
|
42
|
+
this._lastCpuTime = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start monitoring a process.
|
|
47
|
+
* @param {number} pid — the child process PID
|
|
48
|
+
* @param {function} onRestart — callback when restart is needed
|
|
49
|
+
*/
|
|
50
|
+
start(pid, onRestart) {
|
|
51
|
+
this.pid = pid;
|
|
52
|
+
this._onRestart = onRestart;
|
|
53
|
+
this._running = true;
|
|
54
|
+
this._consecutiveGrowth = 0;
|
|
55
|
+
this._samples = [];
|
|
56
|
+
this._lastCpuUsage = null;
|
|
57
|
+
this._lastCpuTime = null;
|
|
58
|
+
|
|
59
|
+
this._timer = setInterval(() => this._sample(), this.sampleIntervalMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
stop() {
|
|
63
|
+
this._running = false;
|
|
64
|
+
if (this._timer) {
|
|
65
|
+
clearInterval(this._timer);
|
|
66
|
+
this._timer = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reset for new process (after restart).
|
|
72
|
+
*/
|
|
73
|
+
reset(newPid) {
|
|
74
|
+
this.pid = newPid;
|
|
75
|
+
this._consecutiveGrowth = 0;
|
|
76
|
+
this._samples = [];
|
|
77
|
+
this._lastCpuUsage = null;
|
|
78
|
+
this._lastCpuTime = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get current analytics snapshot.
|
|
83
|
+
*/
|
|
84
|
+
getMetrics() {
|
|
85
|
+
const latest = this._samples.length > 0 ? this._samples[this._samples.length - 1] : null;
|
|
86
|
+
const avgCpu = this._cpuSamples.length > 0
|
|
87
|
+
? Math.round(this._cpuSamples.reduce((a, b) => a + b, 0) / this._cpuSamples.length)
|
|
88
|
+
: 0;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
pid: this.pid,
|
|
92
|
+
alive: this._isAlive(),
|
|
93
|
+
current: latest ? {
|
|
94
|
+
rss: latest.rss,
|
|
95
|
+
heap: latest.heap,
|
|
96
|
+
cpu: latest.cpu,
|
|
97
|
+
} : null,
|
|
98
|
+
peak: {
|
|
99
|
+
memory: this._peakMemory,
|
|
100
|
+
},
|
|
101
|
+
average: {
|
|
102
|
+
memory: this._sampleCount > 0 ? Math.round(this._avgMemory / this._sampleCount) : 0,
|
|
103
|
+
cpu: avgCpu,
|
|
104
|
+
},
|
|
105
|
+
leakDetection: {
|
|
106
|
+
consecutiveGrowth: this._consecutiveGrowth,
|
|
107
|
+
threshold: this.leakSamples,
|
|
108
|
+
warning: this._consecutiveGrowth >= Math.floor(this.leakSamples * 0.7),
|
|
109
|
+
},
|
|
110
|
+
samples: this._samples.slice(-30).map(s => ({
|
|
111
|
+
t: s.timestamp,
|
|
112
|
+
rss: s.rss,
|
|
113
|
+
heap: s.heap,
|
|
114
|
+
cpu: s.cpu,
|
|
115
|
+
})),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// -- Private --
|
|
120
|
+
|
|
121
|
+
_sample() {
|
|
122
|
+
if (!this._running || !this.pid) return;
|
|
123
|
+
|
|
124
|
+
if (!this._isAlive()) {
|
|
125
|
+
// Process died — runner will handle this via exit event
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// Get memory from process
|
|
131
|
+
const memInfo = this._getMemory();
|
|
132
|
+
const cpuPercent = this._getCpu();
|
|
133
|
+
|
|
134
|
+
const sample = {
|
|
135
|
+
timestamp: Date.now(),
|
|
136
|
+
rss: memInfo.rss,
|
|
137
|
+
heap: memInfo.heap,
|
|
138
|
+
cpu: cpuPercent,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
this._samples.push(sample);
|
|
142
|
+
if (this._samples.length > 100) this._samples.shift();
|
|
143
|
+
|
|
144
|
+
// Track peaks/averages
|
|
145
|
+
if (memInfo.rss > this._peakMemory) this._peakMemory = memInfo.rss;
|
|
146
|
+
this._avgMemory += memInfo.rss;
|
|
147
|
+
this._sampleCount++;
|
|
148
|
+
|
|
149
|
+
this._cpuSamples.push(cpuPercent);
|
|
150
|
+
if (this._cpuSamples.length > 60) this._cpuSamples.shift();
|
|
151
|
+
|
|
152
|
+
// Check memory threshold
|
|
153
|
+
if (memInfo.rss > this.maxMemoryMB) {
|
|
154
|
+
console.log(chalk.red(` 🚨 Memory limit exceeded: ${memInfo.rss}MB > ${this.maxMemoryMB}MB`));
|
|
155
|
+
if (this.logger) {
|
|
156
|
+
this.logger.error("process.memory_exceeded", `Memory ${memInfo.rss}MB exceeds ${this.maxMemoryMB}MB`, sample);
|
|
157
|
+
}
|
|
158
|
+
if (this._onRestart) this._onRestart("memory_exceeded");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Leak detection: memory growing for N consecutive samples
|
|
163
|
+
if (this._samples.length >= 2) {
|
|
164
|
+
const prev = this._samples[this._samples.length - 2];
|
|
165
|
+
if (sample.rss > prev.rss) {
|
|
166
|
+
this._consecutiveGrowth++;
|
|
167
|
+
} else {
|
|
168
|
+
this._consecutiveGrowth = 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (this._consecutiveGrowth >= this.leakSamples) {
|
|
172
|
+
const growth = sample.rss - this._samples[this._samples.length - this.leakSamples].rss;
|
|
173
|
+
console.log(chalk.yellow(` ⚠️ Memory leak detected: +${growth}MB over ${this.leakSamples} samples`));
|
|
174
|
+
if (this.logger) {
|
|
175
|
+
this.logger.warn("process.memory_leak", `Memory leak: +${growth}MB over ${this.leakSamples} samples`, { growth, rss: sample.rss });
|
|
176
|
+
}
|
|
177
|
+
if (this._onRestart) this._onRestart("memory_leak");
|
|
178
|
+
this._consecutiveGrowth = 0;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch {}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_getMemory() {
|
|
186
|
+
try {
|
|
187
|
+
// On Windows, use tasklist; on Unix, use /proc
|
|
188
|
+
if (process.platform === "win32") {
|
|
189
|
+
const output = execSync(`tasklist /FI "PID eq ${this.pid}" /FO CSV /NH`, { encoding: "utf-8", timeout: 3000 });
|
|
190
|
+
const match = output.match(/"(\d[\d,]+)\s*K"/);
|
|
191
|
+
if (match) {
|
|
192
|
+
const kb = parseInt(match[1].replace(/,/g, ""), 10);
|
|
193
|
+
return { rss: Math.round(kb / 1024), heap: 0 };
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
const output = execSync(`ps -o rss= -p ${this.pid}`, { encoding: "utf-8", timeout: 3000 });
|
|
197
|
+
const kb = parseInt(output.trim(), 10);
|
|
198
|
+
return { rss: Math.round(kb / 1024), heap: 0 };
|
|
199
|
+
}
|
|
200
|
+
} catch {}
|
|
201
|
+
return { rss: 0, heap: 0 };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
_getCpu() {
|
|
205
|
+
try {
|
|
206
|
+
if (process.platform === "win32") {
|
|
207
|
+
const output = execSync(
|
|
208
|
+
`wmic path Win32_PerfFormattedData_PerfProc_Process where "IDProcess=${this.pid}" get PercentProcessorTime /VALUE`,
|
|
209
|
+
{ encoding: "utf-8", timeout: 3000 }
|
|
210
|
+
);
|
|
211
|
+
const match = output.match(/PercentProcessorTime=(\d+)/);
|
|
212
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
213
|
+
} else {
|
|
214
|
+
const output = execSync(`ps -o %cpu= -p ${this.pid}`, { encoding: "utf-8", timeout: 3000 });
|
|
215
|
+
return Math.round(parseFloat(output.trim()));
|
|
216
|
+
}
|
|
217
|
+
} catch {}
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_isAlive() {
|
|
222
|
+
try {
|
|
223
|
+
process.kill(this.pid, 0); // signal 0 = check if alive
|
|
224
|
+
return true;
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { ProcessMonitor };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
const http = require("http");
|
|
2
|
+
const chalk = require("chalk");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Route Prober — discovers and tests ALL server routes periodically.
|
|
6
|
+
*
|
|
7
|
+
* Instead of only checking /health, probes every route discovered
|
|
8
|
+
* in the function map. Tracks response times per endpoint over time.
|
|
9
|
+
*
|
|
10
|
+
* Adapts automatically: when the function map updates (new routes added),
|
|
11
|
+
* the prober picks them up on the next cycle.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
class RouteProber {
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.port = options.port || parseInt(process.env.PORT, 10) || 3000;
|
|
17
|
+
this.logger = options.logger;
|
|
18
|
+
this.brain = options.brain;
|
|
19
|
+
this.intervalMs = options.intervalMs || 30000; // probe every 30s
|
|
20
|
+
|
|
21
|
+
// Per-route analytics
|
|
22
|
+
this._routeMetrics = {}; // path → { samples[], avg, min, max, errors, lastStatus }
|
|
23
|
+
this._timer = null;
|
|
24
|
+
this._running = false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
start() {
|
|
28
|
+
this._running = true;
|
|
29
|
+
// First probe after a delay to let server boot
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
this._probe();
|
|
32
|
+
this._timer = setInterval(() => this._probe(), this.intervalMs);
|
|
33
|
+
}, 15000);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
stop() {
|
|
37
|
+
this._running = false;
|
|
38
|
+
if (this._timer) {
|
|
39
|
+
clearInterval(this._timer);
|
|
40
|
+
this._timer = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get analytics for all probed routes.
|
|
46
|
+
*/
|
|
47
|
+
getMetrics() {
|
|
48
|
+
const result = {};
|
|
49
|
+
for (const [path, m] of Object.entries(this._routeMetrics)) {
|
|
50
|
+
const samples = m.samples;
|
|
51
|
+
const avg = samples.length > 0 ? Math.round(samples.reduce((a, b) => a + b, 0) / samples.length) : 0;
|
|
52
|
+
result[path] = {
|
|
53
|
+
avgMs: avg,
|
|
54
|
+
minMs: m.min,
|
|
55
|
+
maxMs: m.max,
|
|
56
|
+
samples: samples.length,
|
|
57
|
+
errors: m.errors,
|
|
58
|
+
lastStatus: m.lastStatus,
|
|
59
|
+
lastProbe: m.lastProbe,
|
|
60
|
+
healthy: m.lastStatus >= 200 && m.lastStatus < 400,
|
|
61
|
+
// Response time trend (last 5 vs overall)
|
|
62
|
+
trend: this._calcTrend(samples),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get a summary suitable for the dashboard.
|
|
70
|
+
*/
|
|
71
|
+
getSummary() {
|
|
72
|
+
const metrics = this.getMetrics();
|
|
73
|
+
const routes = Object.keys(metrics);
|
|
74
|
+
const healthy = routes.filter(r => metrics[r].healthy).length;
|
|
75
|
+
const unhealthy = routes.filter(r => !metrics[r].healthy).length;
|
|
76
|
+
const slowest = routes.sort((a, b) => (metrics[b].avgMs || 0) - (metrics[a].avgMs || 0))[0];
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
totalRoutes: routes.length,
|
|
80
|
+
healthy,
|
|
81
|
+
unhealthy,
|
|
82
|
+
slowest: slowest ? { path: slowest, avgMs: metrics[slowest].avgMs } : null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async _probe() {
|
|
87
|
+
if (!this._running) return;
|
|
88
|
+
|
|
89
|
+
// Get current routes from brain's function map
|
|
90
|
+
let routes = [];
|
|
91
|
+
if (this.brain && this.brain.functionMap) {
|
|
92
|
+
routes = (this.brain.functionMap.routes || [])
|
|
93
|
+
.filter(r => r.method === "GET" || r.method === "*")
|
|
94
|
+
.map(r => r.path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Always include root and health
|
|
98
|
+
if (!routes.includes("/")) routes.unshift("/");
|
|
99
|
+
if (!routes.includes("/health")) routes.push("/health");
|
|
100
|
+
|
|
101
|
+
// Deduplicate
|
|
102
|
+
routes = [...new Set(routes)];
|
|
103
|
+
|
|
104
|
+
for (const routePath of routes) {
|
|
105
|
+
await this._probeRoute(routePath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_probeRoute(routePath) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
const startTime = Date.now();
|
|
112
|
+
|
|
113
|
+
const req = http.get({
|
|
114
|
+
hostname: "127.0.0.1",
|
|
115
|
+
port: this.port,
|
|
116
|
+
path: routePath,
|
|
117
|
+
timeout: 5000,
|
|
118
|
+
}, (res) => {
|
|
119
|
+
const responseTime = Date.now() - startTime;
|
|
120
|
+
let body = "";
|
|
121
|
+
res.on("data", (d) => { body += d; });
|
|
122
|
+
res.on("end", () => {
|
|
123
|
+
this._record(routePath, responseTime, res.statusCode);
|
|
124
|
+
resolve();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
req.on("timeout", () => {
|
|
129
|
+
req.destroy();
|
|
130
|
+
const responseTime = Date.now() - startTime;
|
|
131
|
+
this._record(routePath, responseTime, 0);
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
req.on("error", () => {
|
|
136
|
+
const responseTime = Date.now() - startTime;
|
|
137
|
+
this._record(routePath, responseTime, 0);
|
|
138
|
+
resolve();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_record(routePath, responseTime, statusCode) {
|
|
144
|
+
if (!this._routeMetrics[routePath]) {
|
|
145
|
+
this._routeMetrics[routePath] = {
|
|
146
|
+
samples: [],
|
|
147
|
+
min: Infinity,
|
|
148
|
+
max: 0,
|
|
149
|
+
errors: 0,
|
|
150
|
+
lastStatus: 0,
|
|
151
|
+
lastProbe: 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const m = this._routeMetrics[routePath];
|
|
156
|
+
m.samples.push(responseTime);
|
|
157
|
+
if (m.samples.length > 60) m.samples.shift(); // keep last 60 samples
|
|
158
|
+
m.min = Math.min(m.min, responseTime);
|
|
159
|
+
m.max = Math.max(m.max, responseTime);
|
|
160
|
+
m.lastStatus = statusCode;
|
|
161
|
+
m.lastProbe = Date.now();
|
|
162
|
+
|
|
163
|
+
if (statusCode === 0 || statusCode >= 500) {
|
|
164
|
+
m.errors++;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Log slow routes
|
|
168
|
+
if (responseTime > 2000) {
|
|
169
|
+
console.log(chalk.yellow(` ⚡ Slow route: ${routePath} took ${responseTime}ms`));
|
|
170
|
+
if (this.logger) {
|
|
171
|
+
this.logger.warn("perf.slow_route", `${routePath}: ${responseTime}ms`, { path: routePath, ms: responseTime, status: statusCode });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
_calcTrend(samples) {
|
|
177
|
+
if (samples.length < 6) return "stable";
|
|
178
|
+
const recent = samples.slice(-5);
|
|
179
|
+
const older = samples.slice(-10, -5);
|
|
180
|
+
if (older.length === 0) return "stable";
|
|
181
|
+
|
|
182
|
+
const recentAvg = recent.reduce((a, b) => a + b, 0) / recent.length;
|
|
183
|
+
const olderAvg = older.reduce((a, b) => a + b, 0) / older.length;
|
|
184
|
+
|
|
185
|
+
if (recentAvg > olderAvg * 1.5) return "degrading";
|
|
186
|
+
if (recentAvg < olderAvg * 0.7) return "improving";
|
|
187
|
+
return "stable";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { RouteProber };
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const chalk = require("chalk");
|
|
2
|
+
const { aiCall } = require("../core/ai-client");
|
|
3
|
+
const { getModel } = require("../core/models");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Notification System — alerts for issues the AI cannot fix.
|
|
7
|
+
*
|
|
8
|
+
* Some errors require human intervention:
|
|
9
|
+
* - API keys expired/rotated/revoked
|
|
10
|
+
* - Billing/quota exceeded
|
|
11
|
+
* - External service down (database, third-party API)
|
|
12
|
+
* - Permission denied (file system, network)
|
|
13
|
+
* - Certificate errors
|
|
14
|
+
* - Environment misconfiguration
|
|
15
|
+
*
|
|
16
|
+
* The notifier:
|
|
17
|
+
* 1. Classifies errors as AI-fixable vs human-required
|
|
18
|
+
* 2. Summarizes human-required issues into short, clear sentences
|
|
19
|
+
* 3. Sanitizes through the redactor (no secrets in notifications)
|
|
20
|
+
* 4. Delivers via console, dashboard events, and optional webhook
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Patterns that indicate human-required intervention
|
|
24
|
+
const HUMAN_REQUIRED_PATTERNS = [
|
|
25
|
+
// API/Auth failures
|
|
26
|
+
{ pattern: /401\s*unauthorized/i, category: "auth", hint: "API key or credentials may be invalid or expired" },
|
|
27
|
+
{ pattern: /403\s*forbidden/i, category: "auth", hint: "Access denied — check permissions or API key scope" },
|
|
28
|
+
{ pattern: /invalid.*(api|auth|token|key|credential)/i, category: "auth", hint: "Authentication credential is invalid" },
|
|
29
|
+
{ pattern: /(api|auth|token|key|credential).*(expired|revoked|rotated|invalid)/i, category: "auth", hint: "Credential has expired or been revoked" },
|
|
30
|
+
{ pattern: /authentication\s+failed/i, category: "auth", hint: "Authentication failed — check credentials" },
|
|
31
|
+
|
|
32
|
+
// Billing/Quota
|
|
33
|
+
{ pattern: /429\s*(too many|rate limit)/i, category: "billing", hint: "Rate limit hit — may need to upgrade plan or wait" },
|
|
34
|
+
{ pattern: /(quota|limit|credits?)\s*(exceeded|exhausted|depleted)/i, category: "billing", hint: "Usage quota or credits exhausted" },
|
|
35
|
+
{ pattern: /billing.*(?:issue|error|failed|inactive)/i, category: "billing", hint: "Billing issue on the account" },
|
|
36
|
+
{ pattern: /insufficient.*(funds|credits|quota)/i, category: "billing", hint: "Insufficient credits or funds" },
|
|
37
|
+
|
|
38
|
+
// External service failures
|
|
39
|
+
{ pattern: /ECONNREFUSED/i, category: "service", hint: "External service connection refused — is it running?" },
|
|
40
|
+
{ pattern: /ENOTFOUND/i, category: "service", hint: "DNS lookup failed — check hostname or network" },
|
|
41
|
+
{ pattern: /ETIMEDOUT/i, category: "service", hint: "Connection timed out — service may be down" },
|
|
42
|
+
{ pattern: /ECONNRESET/i, category: "service", hint: "Connection reset by remote server" },
|
|
43
|
+
{ pattern: /503\s*service\s*unavailable/i, category: "service", hint: "External service is temporarily unavailable" },
|
|
44
|
+
{ pattern: /502\s*bad\s*gateway/i, category: "service", hint: "Bad gateway — upstream service may be down" },
|
|
45
|
+
|
|
46
|
+
// Certificates
|
|
47
|
+
{ pattern: /CERT_|certificate|SSL|TLS/i, category: "cert", hint: "SSL/TLS certificate issue — may need renewal" },
|
|
48
|
+
{ pattern: /self.signed/i, category: "cert", hint: "Self-signed certificate rejected" },
|
|
49
|
+
|
|
50
|
+
// Permissions
|
|
51
|
+
{ pattern: /EACCES/i, category: "permission", hint: "Permission denied on file system" },
|
|
52
|
+
{ pattern: /EPERM/i, category: "permission", hint: "Operation not permitted — check file/process permissions" },
|
|
53
|
+
|
|
54
|
+
// Environment
|
|
55
|
+
{ pattern: /not\s+set|undefined.*env|missing.*env/i, category: "env", hint: "Environment variable not configured" },
|
|
56
|
+
{ pattern: /missing.*config/i, category: "env", hint: "Configuration file or value missing" },
|
|
57
|
+
|
|
58
|
+
// Disk
|
|
59
|
+
{ pattern: /ENOSPC/i, category: "disk", hint: "Disk space full" },
|
|
60
|
+
{ pattern: /ENOMEM/i, category: "disk", hint: "Out of memory" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const CATEGORY_ICONS = {
|
|
64
|
+
auth: "🔑",
|
|
65
|
+
billing: "💳",
|
|
66
|
+
service: "🌐",
|
|
67
|
+
cert: "📜",
|
|
68
|
+
permission: "🔒",
|
|
69
|
+
env: "⚙️",
|
|
70
|
+
disk: "💾",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
class Notifier {
|
|
74
|
+
constructor(options = {}) {
|
|
75
|
+
this.logger = options.logger;
|
|
76
|
+
this.redactor = options.redactor;
|
|
77
|
+
this.webhookUrl = options.webhookUrl || process.env.WOLVERINE_WEBHOOK_URL || null;
|
|
78
|
+
|
|
79
|
+
// Dedup: don't spam the same notification repeatedly
|
|
80
|
+
this._sentNotifications = new Map(); // key → timestamp
|
|
81
|
+
this._dedupeWindowMs = 300000; // 5 minutes
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Classify an error: can AI fix it, or does it need human intervention?
|
|
86
|
+
* Returns { humanRequired: boolean, category?, hint?, matches? }
|
|
87
|
+
*/
|
|
88
|
+
classify(errorMessage, stackTrace) {
|
|
89
|
+
const combined = `${errorMessage}\n${stackTrace || ""}`;
|
|
90
|
+
const matches = [];
|
|
91
|
+
|
|
92
|
+
for (const { pattern, category, hint } of HUMAN_REQUIRED_PATTERNS) {
|
|
93
|
+
if (pattern.test(combined)) {
|
|
94
|
+
matches.push({ category, hint });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (matches.length === 0) {
|
|
99
|
+
return { humanRequired: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Use the most specific match (first one)
|
|
103
|
+
return {
|
|
104
|
+
humanRequired: true,
|
|
105
|
+
category: matches[0].category,
|
|
106
|
+
hint: matches[0].hint,
|
|
107
|
+
matches,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Process an error — classify it, and if human-required, generate and send a notification.
|
|
113
|
+
* Returns the notification object if sent, null if AI-fixable.
|
|
114
|
+
*/
|
|
115
|
+
async notify(errorMessage, stackTrace) {
|
|
116
|
+
const classification = this.classify(errorMessage, stackTrace);
|
|
117
|
+
if (!classification.humanRequired) return null;
|
|
118
|
+
|
|
119
|
+
// Dedup check
|
|
120
|
+
const dedupeKey = `${classification.category}:${classification.hint}`;
|
|
121
|
+
const lastSent = this._sentNotifications.get(dedupeKey);
|
|
122
|
+
if (lastSent && Date.now() - lastSent < this._dedupeWindowMs) {
|
|
123
|
+
return null; // already notified recently
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Sanitize through redactor
|
|
127
|
+
const safeError = this.redactor ? this.redactor.redact(errorMessage) : errorMessage;
|
|
128
|
+
const safeStack = this.redactor ? this.redactor.redact(stackTrace || "") : (stackTrace || "");
|
|
129
|
+
|
|
130
|
+
// Generate short summary using AI
|
|
131
|
+
let summary;
|
|
132
|
+
try {
|
|
133
|
+
summary = await this._summarize(safeError, safeStack, classification);
|
|
134
|
+
} catch {
|
|
135
|
+
// If AI call fails (maybe THAT's the auth error), use the pattern hint
|
|
136
|
+
summary = classification.hint;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const notification = {
|
|
140
|
+
category: classification.category,
|
|
141
|
+
icon: CATEGORY_ICONS[classification.category] || "⚠️",
|
|
142
|
+
summary,
|
|
143
|
+
hint: classification.hint,
|
|
144
|
+
timestamp: Date.now(),
|
|
145
|
+
iso: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Mark as sent for dedup
|
|
149
|
+
this._sentNotifications.set(dedupeKey, Date.now());
|
|
150
|
+
|
|
151
|
+
// Deliver through all channels
|
|
152
|
+
this._deliverConsole(notification);
|
|
153
|
+
this._deliverLogger(notification);
|
|
154
|
+
this._deliverWebhook(notification);
|
|
155
|
+
|
|
156
|
+
return notification;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Use CHAT_MODEL to summarize the issue in 1-2 short sentences.
|
|
161
|
+
*/
|
|
162
|
+
async _summarize(safeError, safeStack, classification) {
|
|
163
|
+
let model;
|
|
164
|
+
try {
|
|
165
|
+
model = getModel("chat");
|
|
166
|
+
} catch {
|
|
167
|
+
return classification.hint;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const result = await aiCall({
|
|
171
|
+
model,
|
|
172
|
+
systemPrompt: "You summarize server errors for developers. Write 1-2 short sentences. Be direct and actionable. Do not include any secrets, passwords, or API key values — only refer to them by name (e.g. 'the OPENAI_API_KEY').",
|
|
173
|
+
userPrompt: `Summarize this error for a developer notification:\n\nCategory: ${classification.category}\nError: ${safeError}\n\nStack (first 300 chars): ${safeStack.slice(0, 300)}`,
|
|
174
|
+
maxTokens: 100,
|
|
175
|
+
category: "security",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Double-sanitize the AI response (in case the AI echoes something)
|
|
179
|
+
const summary = this.redactor ? this.redactor.redact(result.content) : result.content;
|
|
180
|
+
return summary || classification.hint;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_deliverConsole(notification) {
|
|
184
|
+
console.log(chalk.red.bold(`\n ${notification.icon} HUMAN ACTION REQUIRED`));
|
|
185
|
+
console.log(chalk.red(` Category: ${notification.category}`));
|
|
186
|
+
console.log(chalk.yellow(` ${notification.summary}`));
|
|
187
|
+
console.log(chalk.gray(` Hint: ${notification.hint}\n`));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_deliverLogger(notification) {
|
|
191
|
+
if (!this.logger) return;
|
|
192
|
+
this.logger.critical("notify.human_required", notification.summary, {
|
|
193
|
+
category: notification.category,
|
|
194
|
+
hint: notification.hint,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async _deliverWebhook(notification) {
|
|
199
|
+
if (!this.webhookUrl) return;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const http = this.webhookUrl.startsWith("https") ? require("https") : require("http");
|
|
203
|
+
const url = new URL(this.webhookUrl);
|
|
204
|
+
const payload = JSON.stringify({
|
|
205
|
+
text: `${notification.icon} **Wolverine Alert** [${notification.category}]: ${notification.summary}`,
|
|
206
|
+
...notification,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const req = http.request({
|
|
210
|
+
hostname: url.hostname,
|
|
211
|
+
port: url.port,
|
|
212
|
+
path: url.pathname + url.search,
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
|
|
215
|
+
timeout: 5000,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
req.on("error", () => {}); // swallow webhook errors
|
|
219
|
+
req.write(payload);
|
|
220
|
+
req.end();
|
|
221
|
+
} catch {
|
|
222
|
+
// Webhook delivery is best-effort
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = { Notifier, HUMAN_REQUIRED_PATTERNS, CATEGORY_ICONS };
|