wolverine-ai 1.2.0 → 1.3.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/README.md +41 -15
- package/package.json +1 -1
- package/src/agent/agent-engine.js +17 -1
- package/src/agent/sub-agents.js +3 -3
- package/src/backup/backup-manager.js +117 -127
- package/src/brain/brain.js +20 -4
- package/src/core/ai-client.js +12 -3
- package/src/core/error-parser.js +40 -1
- package/src/core/runner.js +22 -1
- package/src/core/wolverine.js +125 -6
- package/src/dashboard/server.js +224 -5
- package/src/security/admin-auth.js +23 -4
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ wolverine/
|
|
|
73
73
|
│ │ ├── ai-client.js ← OpenAI client (Chat + Responses API)
|
|
74
74
|
│ │ ├── models.js ← 10-model configuration system
|
|
75
75
|
│ │ ├── verifier.js ← Fix verification (syntax + boot probe)
|
|
76
|
-
│ │ ├── error-parser.js ← Stack trace parsing
|
|
76
|
+
│ │ ├── error-parser.js ← Stack trace parsing + error classification
|
|
77
77
|
│ │ ├── patcher.js ← File patching with sandbox
|
|
78
78
|
│ │ ├── health-monitor.js← PM2-style health checks
|
|
79
79
|
│ │ ├── config.js ← Config loader (settings.json + env)
|
|
@@ -140,26 +140,38 @@ wolverine/
|
|
|
140
140
|
|
|
141
141
|
```
|
|
142
142
|
Server crashes
|
|
143
|
-
→ Error parsed (file, line, message)
|
|
143
|
+
→ Error parsed (file, line, message, errorType)
|
|
144
|
+
→ Error classified: missing_module | missing_file | permission | port_conflict | syntax | runtime
|
|
144
145
|
→ Secrets redacted from error output
|
|
145
146
|
→ Prompt injection scan (AUDIT_MODEL)
|
|
146
147
|
→ Human-required check (expired keys, service down → notify, don't waste tokens)
|
|
147
148
|
→ Rate limit check (error loop → exponential backoff)
|
|
148
149
|
|
|
150
|
+
Operational Fix (zero AI tokens):
|
|
151
|
+
→ "Cannot find module 'cors'" → npm install cors (instant, free)
|
|
152
|
+
→ ENOENT on config file → create missing file with defaults
|
|
153
|
+
→ EACCES/EPERM → chmod 755
|
|
154
|
+
→ If operational fix works → done. No AI needed.
|
|
155
|
+
|
|
149
156
|
Goal Loop (iterate until fixed or exhausted):
|
|
150
157
|
Iteration 1: Fast path (CODING_MODEL, single file, ~1-2k tokens)
|
|
151
|
-
→
|
|
158
|
+
→ AI returns code changes AND/OR shell commands (npm install, mkdir, etc.)
|
|
159
|
+
→ Execute commands first, apply patches second
|
|
160
|
+
→ Verify (syntax check + boot probe) → Pass? Done.
|
|
152
161
|
Iteration 2: Single agent (REASONING_MODEL, multi-file, 10 tools)
|
|
153
|
-
→
|
|
162
|
+
→ Agent has error pattern → fix strategy table
|
|
163
|
+
→ Uses bash_exec for npm install, chmod, config creation
|
|
164
|
+
→ Uses edit_file for code fixes
|
|
165
|
+
→ Verify → Pass? Done.
|
|
154
166
|
Iteration 3: Sub-agents (explore → plan → fix)
|
|
155
167
|
→ Explorer finds relevant files (read-only)
|
|
156
|
-
→ Planner
|
|
157
|
-
→ Fixer
|
|
168
|
+
→ Planner considers operational vs code fixes
|
|
169
|
+
→ Fixer has bash_exec + file tools (can npm install AND edit code)
|
|
158
170
|
→ Deep research (RESEARCH_MODEL) feeds into context
|
|
159
171
|
→ Each failure feeds into the next attempt
|
|
160
172
|
|
|
161
173
|
After fix:
|
|
162
|
-
→ Record to repair history (error, resolution, tokens, cost)
|
|
174
|
+
→ Record to repair history (error, resolution, tokens, cost, mode)
|
|
163
175
|
→ Store in brain for future reference
|
|
164
176
|
→ Promote backup to stable after 30min uptime
|
|
165
177
|
```
|
|
@@ -199,7 +211,7 @@ For complex repairs, wolverine spawns specialized sub-agents that run in sequenc
|
|
|
199
211
|
|-------|--------|-------|------|
|
|
200
212
|
| `explore` | Read-only | REASONING | Investigate codebase, find relevant files |
|
|
201
213
|
| `plan` | Read-only | REASONING | Analyze problem, propose fix strategy |
|
|
202
|
-
| `fix` | Read+write | CODING | Execute targeted fix
|
|
214
|
+
| `fix` | Read+write+shell | CODING | Execute targeted fix — code edits AND npm install/chmod |
|
|
203
215
|
| `verify` | Read-only | REASONING | Check if fix actually works |
|
|
204
216
|
| `research` | Read-only | RESEARCH | Search brain + web for solutions |
|
|
205
217
|
| `security` | Read-only | AUDIT | Audit code for vulnerabilities |
|
|
@@ -224,8 +236,7 @@ Real-time web UI at `http://localhost:PORT+1`:
|
|
|
224
236
|
| **Performance** | Endpoint response times, request rates, error rates |
|
|
225
237
|
| **Command** | Admin chat interface — ask questions or build features |
|
|
226
238
|
| **Analytics** | Memory/CPU charts, route health, per-route response times + trends |
|
|
227
|
-
| **
|
|
228
|
-
| **Backups** | Full server/ snapshot history with status badges |
|
|
239
|
+
| **Backups** | Full backup management: rollback/hot-load buttons, undo, rollback log, admin IP allowlist |
|
|
229
240
|
| **Brain** | Vector store stats (23 seed docs), namespace counts, function map |
|
|
230
241
|
| **Repairs** | Error/resolution audit trail: error, fix, tokens, cost, duration |
|
|
231
242
|
| **Tools** | Agent tool harness listing (10 built-in + MCP) |
|
|
@@ -241,7 +252,7 @@ Three routes (AI-classified per command):
|
|
|
241
252
|
| **TOOLS** | TOOL_MODEL | call_endpoint, read_file, search_brain | Live data, file contents |
|
|
242
253
|
| **AGENT** | CODING_MODEL | Full 10-tool harness | Build features, fix code |
|
|
243
254
|
|
|
244
|
-
Secured with `WOLVERINE_ADMIN_KEY` + localhost
|
|
255
|
+
Secured with `WOLVERINE_ADMIN_KEY` + IP allowlist (localhost + `WOLVERINE_ADMIN_IPS`).
|
|
245
256
|
|
|
246
257
|
---
|
|
247
258
|
|
|
@@ -273,7 +284,7 @@ Reasoning models (`o-series`, `gpt-5-nano`) automatically get 4x token limits to
|
|
|
273
284
|
| **Injection Detector** | Regex layer + AI audit (AUDIT_MODEL) on every error before repair |
|
|
274
285
|
| **Sandbox** | All file operations locked to project directory, symlink escape detection |
|
|
275
286
|
| **Protected Paths** | Agent blocked from modifying wolverine internals (`src/`, `bin/`, etc.) |
|
|
276
|
-
| **Admin Auth** | Dashboard
|
|
287
|
+
| **Admin Auth** | Dashboard requires key + IP allowlist. Localhost always allowed. Remote IPs via `WOLVERINE_ADMIN_IPS` env var or `POST /api/admin/add-ip` at runtime. Timing-safe comparison, lockout after 10 failures |
|
|
277
288
|
| **Rate Limiter** | Sliding window, min gap, hourly budget, exponential backoff on error loops |
|
|
278
289
|
| **MCP Security** | Per-server tool allowlists, arg sanitization, result injection scanning |
|
|
279
290
|
| **SQL Skill** | `sqlGuard()` middleware blocks 15 injection pattern families on all endpoints |
|
|
@@ -409,14 +420,29 @@ All demos use the `server/` directory pattern. Each demo:
|
|
|
409
420
|
|
|
410
421
|
## Backup System
|
|
411
422
|
|
|
412
|
-
Full `server/` directory snapshots:
|
|
423
|
+
Full `server/` directory snapshots with lifecycle management:
|
|
413
424
|
|
|
414
|
-
- Created before every repair attempt and every smart edit
|
|
425
|
+
- Created before every repair attempt and every smart edit (with reason string)
|
|
426
|
+
- Created on graceful shutdown (`createShutdownBackup()`)
|
|
415
427
|
- Includes all files: `.js`, `.json`, `.sql`, `.db`, `.yaml`, configs
|
|
416
428
|
- **Status lifecycle**: UNSTABLE → VERIFIED (fix passed) → STABLE (30min+ uptime)
|
|
417
|
-
- **Retention**: unstable pruned after 7 days, stable keeps 1/day after 7 days
|
|
429
|
+
- **Retention**: unstable/verified pruned after 7 days, stable keeps 1/day after 7 days
|
|
418
430
|
- Atomic writes prevent corruption on kill
|
|
419
431
|
|
|
432
|
+
**Rollback & Recovery:**
|
|
433
|
+
|
|
434
|
+
| Action | What it does |
|
|
435
|
+
|--------|-------------|
|
|
436
|
+
| **Rollback** | Restore any backup — creates a pre-rollback safety backup first, restarts server |
|
|
437
|
+
| **Undo Rollback** | Restore the pre-rollback state if the rollback made things worse |
|
|
438
|
+
| **Hot-load** | Load any backup as the current server state from the dashboard |
|
|
439
|
+
| **Rollback Log** | Full audit trail: timestamp, action, target backup, success/failure |
|
|
440
|
+
|
|
441
|
+
**Dashboard endpoints** (admin auth required):
|
|
442
|
+
- `POST /api/backups/:id/rollback` — rollback to specific backup
|
|
443
|
+
- `POST /api/backups/:id/hotload` — hot-load backup as current state
|
|
444
|
+
- `POST /api/backups/undo` — undo the last rollback
|
|
445
|
+
|
|
420
446
|
---
|
|
421
447
|
|
|
422
448
|
## Skills
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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": {
|
|
@@ -262,11 +262,27 @@ Use these tools systematically:
|
|
|
262
262
|
5. You can edit ANY file type: .js, .json, .sql, .yaml, .env, .dockerfile, .sh, etc.
|
|
263
263
|
6. Prefer edit_file for small targeted fixes, write_file for major changes
|
|
264
264
|
7. Use grep_code to find all usages before renaming something
|
|
265
|
-
8. Use bash_exec to run tests or check dependencies
|
|
265
|
+
8. Use bash_exec to run tests, install packages, or check dependencies
|
|
266
|
+
|
|
267
|
+
CRITICAL — Not every crash is a code bug. Choose the right fix:
|
|
268
|
+
|
|
269
|
+
| Error Pattern | Root Cause | Correct Fix |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| Cannot find module 'X' | Missing npm package | bash_exec: npm install X |
|
|
272
|
+
| Cannot find module './X' | Wrong import path | edit_file: fix the require/import path |
|
|
273
|
+
| ENOENT: no such file | Missing config/data file | write_file: create the missing file |
|
|
274
|
+
| EACCES/EPERM | Permission denied | bash_exec: chmod or fix ownership |
|
|
275
|
+
| EADDRINUSE | Port conflict | bash_exec: kill process on port, or edit config |
|
|
276
|
+
| SyntaxError | Bad code | edit_file: fix the syntax |
|
|
277
|
+
| TypeError/ReferenceError | Logic bug | edit_file: fix the code |
|
|
278
|
+
| MODULE_NOT_FOUND + node_modules | Corrupted install | bash_exec: rm -rf node_modules && npm install |
|
|
279
|
+
|
|
280
|
+
ALWAYS check package.json before editing imports. If a module isn't a local file, use bash_exec to install it.
|
|
266
281
|
|
|
267
282
|
Rules:
|
|
268
283
|
- Read files before modifying them
|
|
269
284
|
- Make minimal, targeted changes
|
|
285
|
+
- Use bash_exec for operational fixes (npm install, chmod, config creation)
|
|
270
286
|
- When done, call the "done" tool with a summary
|
|
271
287
|
|
|
272
288
|
Project root: ${this.cwd}
|
package/src/agent/sub-agents.js
CHANGED
|
@@ -25,7 +25,7 @@ const { getModel } = require("../core/models");
|
|
|
25
25
|
const AGENT_TOOL_SETS = {
|
|
26
26
|
explore: ["read_file", "glob_files", "grep_code", "git_log", "git_diff", "done"],
|
|
27
27
|
plan: ["read_file", "glob_files", "grep_code", "search_brain", "done"],
|
|
28
|
-
fix: ["read_file", "write_file", "edit_file", "glob_files", "grep_code", "done"],
|
|
28
|
+
fix: ["read_file", "write_file", "edit_file", "glob_files", "grep_code", "bash_exec", "done"],
|
|
29
29
|
verify: ["read_file", "glob_files", "grep_code", "bash_exec", "done"],
|
|
30
30
|
research: ["read_file", "grep_code", "web_fetch", "search_brain", "done"],
|
|
31
31
|
security: ["read_file", "glob_files", "grep_code", "done"],
|
|
@@ -46,8 +46,8 @@ const AGENT_CONFIGS = {
|
|
|
46
46
|
// System prompts per agent type
|
|
47
47
|
const AGENT_PROMPTS = {
|
|
48
48
|
explore: "You are an Explorer agent. Your job is to investigate the codebase and find files relevant to the problem. Read files, search for patterns, check git history. Report what you found — do NOT make changes.",
|
|
49
|
-
plan: "You are a Planner agent. Your job is to analyze the problem and propose a fix strategy. Read the relevant files, understand the root cause, and describe step-by-step what needs to change. Do NOT make changes.",
|
|
50
|
-
fix: "You are a Fixer agent. You receive a specific fix plan. Execute it precisely —
|
|
49
|
+
plan: "You are a Planner agent. Your job is to analyze the problem and propose a fix strategy. Read the relevant files, understand the root cause, and describe step-by-step what needs to change. Consider: is this a code bug (edit files) or an operational issue (npm install, create missing config, fix permissions)? Check package.json for dependencies. Do NOT make changes.",
|
|
50
|
+
fix: "You are a Fixer agent. You receive a specific fix plan. Execute it precisely. Use edit_file for code fixes, bash_exec for operational fixes (npm install, chmod, mkdir, config creation). Not every error is a code bug — missing modules need npm install, missing files need creation, permission errors need chmod. Check package.json before editing imports.",
|
|
51
51
|
verify: "You are a Verifier agent. Check if a fix actually works. Read the modified files, look for issues, run tests if available. Report whether the fix is correct.",
|
|
52
52
|
research: "You are a Research agent. Search the brain for past fixes to similar errors, and search the web for solutions. Report your findings.",
|
|
53
53
|
security: "You are a Security agent. Audit the code for vulnerabilities: SQL injection, XSS, path traversal, hardcoded secrets, missing input validation. Report all findings.",
|
|
@@ -1,38 +1,24 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
const chalk = require("chalk");
|
|
4
|
+
const { redact } = require("../security/secret-redactor");
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
+
* Backup Manager — full server/ directory snapshots with lifecycle management.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 3. STABLE: The server ran successfully for STABILITY_THRESHOLD without crashing
|
|
9
|
+
* Lifecycle: UNSTABLE → VERIFIED → STABLE
|
|
10
|
+
* Every backup is a complete copy of server/ (code, configs, databases).
|
|
11
|
+
* Admins can rollback, undo rollbacks, and hot-load any backup state.
|
|
12
12
|
*
|
|
13
|
-
* Retention
|
|
14
|
-
*
|
|
15
|
-
* - Verified backups: kept for 7 days, then pruned unless promoted to stable
|
|
16
|
-
* - Stable backups: after 7 days, keep only 1 per day (most recent each day)
|
|
17
|
-
*
|
|
18
|
-
* Storage layout:
|
|
19
|
-
* .wolverine/
|
|
20
|
-
* backups/
|
|
21
|
-
* manifest.json — tracks all backups with metadata
|
|
22
|
-
* <timestamp>/ — one directory per backup event
|
|
23
|
-
* <filename>.bak — the original file content
|
|
13
|
+
* Retention: unstable/verified pruned after 7 days.
|
|
14
|
+
* Stable backups older than 7 days → keep 1 per day (most recent).
|
|
24
15
|
*/
|
|
25
16
|
|
|
26
17
|
const WOLVERINE_DIR = ".wolverine";
|
|
27
18
|
const BACKUPS_DIR = path.join(WOLVERINE_DIR, "backups");
|
|
28
19
|
const MANIFEST_FILE = path.join(BACKUPS_DIR, "manifest.json");
|
|
29
|
-
|
|
30
|
-
// Stability threshold: how long the server must run without the same crash
|
|
31
|
-
// to consider a fix "stable" (default: 30 minutes)
|
|
32
20
|
const STABILITY_THRESHOLD_MS = 30 * 60 * 1000;
|
|
33
|
-
|
|
34
|
-
// Retention: unstable/verified backups older than this are pruned
|
|
35
|
-
const RETENTION_UNSTABLE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
21
|
+
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
36
22
|
|
|
37
23
|
class BackupManager {
|
|
38
24
|
constructor(projectRoot) {
|
|
@@ -44,30 +30,25 @@ class BackupManager {
|
|
|
44
30
|
}
|
|
45
31
|
|
|
46
32
|
/**
|
|
47
|
-
* Create a
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* @param {string[]|null} filePaths — specific files, or null to backup entire server/
|
|
33
|
+
* Create a full server/ backup.
|
|
34
|
+
* @param {string} reason — why this backup was created
|
|
35
|
+
* @returns {string} backupId
|
|
51
36
|
*/
|
|
52
|
-
createBackup(
|
|
37
|
+
createBackup(reason = "manual") {
|
|
53
38
|
const backupId = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6);
|
|
54
39
|
const timestamp = Date.now();
|
|
55
40
|
const backupDir = path.join(this.backupsDir, backupId);
|
|
56
41
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
57
42
|
|
|
58
|
-
|
|
59
|
-
if (!filePaths || filePaths.length === 0) {
|
|
60
|
-
filePaths = this._collectServerFiles();
|
|
61
|
-
}
|
|
62
|
-
|
|
43
|
+
const filePaths = this._collectServerFiles();
|
|
63
44
|
const files = [];
|
|
45
|
+
|
|
64
46
|
for (const filePath of filePaths) {
|
|
65
47
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.projectRoot, filePath);
|
|
66
48
|
if (!fs.existsSync(absPath)) continue;
|
|
67
|
-
// Skip large files (>10MB) and binary blobs
|
|
68
49
|
try {
|
|
69
50
|
const stat = fs.statSync(absPath);
|
|
70
|
-
if (stat.size > 10 * 1024 * 1024) continue;
|
|
51
|
+
if (stat.size > 10 * 1024 * 1024) continue; // skip >10MB
|
|
71
52
|
} catch { continue; }
|
|
72
53
|
|
|
73
54
|
const relativePath = path.relative(this.projectRoot, absPath);
|
|
@@ -85,7 +66,9 @@ class BackupManager {
|
|
|
85
66
|
id: backupId,
|
|
86
67
|
timestamp,
|
|
87
68
|
status: "unstable",
|
|
69
|
+
reason: redact(reason),
|
|
88
70
|
files,
|
|
71
|
+
fileCount: files.length,
|
|
89
72
|
errorSignature: null,
|
|
90
73
|
promotedAt: null,
|
|
91
74
|
verifiedAt: null,
|
|
@@ -94,22 +77,29 @@ class BackupManager {
|
|
|
94
77
|
this.manifest.backups.push(entry);
|
|
95
78
|
this._saveManifest();
|
|
96
79
|
|
|
80
|
+
console.log(chalk.gray(` 💾 Backup ${backupId} (${files.length} files) — ${reason}`));
|
|
97
81
|
return backupId;
|
|
98
82
|
}
|
|
99
83
|
|
|
100
84
|
/**
|
|
101
|
-
* Rollback to a specific backup.
|
|
85
|
+
* Rollback to a specific backup. Creates a pre-rollback backup first.
|
|
86
|
+
* @returns {{ success, preRollbackId }}
|
|
102
87
|
*/
|
|
103
88
|
rollbackTo(backupId) {
|
|
104
89
|
const entry = this.manifest.backups.find(b => b.id === backupId);
|
|
105
90
|
if (!entry) {
|
|
106
91
|
console.log(chalk.red(`Backup ${backupId} not found.`));
|
|
107
|
-
return false;
|
|
92
|
+
return { success: false };
|
|
108
93
|
}
|
|
109
94
|
|
|
95
|
+
// Create a pre-rollback backup so admins can undo
|
|
96
|
+
const preRollbackId = this.createBackup(`pre-rollback (before restoring ${backupId})`);
|
|
97
|
+
|
|
110
98
|
let allRestored = true;
|
|
111
99
|
for (const file of entry.files) {
|
|
112
100
|
if (fs.existsSync(file.backup)) {
|
|
101
|
+
// Ensure parent dir exists
|
|
102
|
+
fs.mkdirSync(path.dirname(file.original), { recursive: true });
|
|
113
103
|
fs.copyFileSync(file.backup, file.original);
|
|
114
104
|
console.log(chalk.yellow(` ↩️ Restored: ${file.relative}`));
|
|
115
105
|
} else {
|
|
@@ -118,22 +108,64 @@ class BackupManager {
|
|
|
118
108
|
}
|
|
119
109
|
}
|
|
120
110
|
|
|
121
|
-
|
|
111
|
+
// Log the rollback
|
|
112
|
+
if (!this.manifest.rollbackLog) this.manifest.rollbackLog = [];
|
|
113
|
+
this.manifest.rollbackLog.push({
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
restoredBackupId: backupId,
|
|
116
|
+
preRollbackBackupId: preRollbackId,
|
|
117
|
+
success: allRestored,
|
|
118
|
+
});
|
|
119
|
+
this._saveManifest();
|
|
120
|
+
|
|
121
|
+
return { success: allRestored, preRollbackId };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
* Rollback
|
|
125
|
+
* Rollback the most recent backup.
|
|
126
126
|
*/
|
|
127
127
|
rollbackLatest() {
|
|
128
|
-
if (this.manifest.backups.length === 0) return false;
|
|
128
|
+
if (this.manifest.backups.length === 0) return { success: false };
|
|
129
129
|
const latest = this.manifest.backups[this.manifest.backups.length - 1];
|
|
130
|
-
console.log(chalk.yellow(`\n↩️ Rolling back to
|
|
130
|
+
console.log(chalk.yellow(`\n↩️ Rolling back to ${latest.id} (${new Date(latest.timestamp).toISOString()})...`));
|
|
131
131
|
return this.rollbackTo(latest.id);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
*
|
|
135
|
+
* Undo the last rollback — restores the pre-rollback state.
|
|
136
136
|
*/
|
|
137
|
+
undoRollback() {
|
|
138
|
+
if (!this.manifest.rollbackLog || this.manifest.rollbackLog.length === 0) {
|
|
139
|
+
console.log(chalk.red("No rollback to undo."));
|
|
140
|
+
return { success: false };
|
|
141
|
+
}
|
|
142
|
+
const lastRollback = this.manifest.rollbackLog[this.manifest.rollbackLog.length - 1];
|
|
143
|
+
console.log(chalk.yellow(`\n↩️ Undoing rollback — restoring pre-rollback state ${lastRollback.preRollbackBackupId}...`));
|
|
144
|
+
|
|
145
|
+
const entry = this.manifest.backups.find(b => b.id === lastRollback.preRollbackBackupId);
|
|
146
|
+
if (!entry) {
|
|
147
|
+
console.log(chalk.red("Pre-rollback backup not found."));
|
|
148
|
+
return { success: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let allRestored = true;
|
|
152
|
+
for (const file of entry.files) {
|
|
153
|
+
if (fs.existsSync(file.backup)) {
|
|
154
|
+
fs.mkdirSync(path.dirname(file.original), { recursive: true });
|
|
155
|
+
fs.copyFileSync(file.backup, file.original);
|
|
156
|
+
} else { allRestored = false; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.manifest.rollbackLog.push({
|
|
160
|
+
timestamp: Date.now(),
|
|
161
|
+
action: "undo",
|
|
162
|
+
restoredBackupId: lastRollback.preRollbackBackupId,
|
|
163
|
+
success: allRestored,
|
|
164
|
+
});
|
|
165
|
+
this._saveManifest();
|
|
166
|
+
return { success: allRestored };
|
|
167
|
+
}
|
|
168
|
+
|
|
137
169
|
markVerified(backupId) {
|
|
138
170
|
const entry = this.manifest.backups.find(b => b.id === backupId);
|
|
139
171
|
if (entry && entry.status === "unstable") {
|
|
@@ -143,9 +175,6 @@ class BackupManager {
|
|
|
143
175
|
}
|
|
144
176
|
}
|
|
145
177
|
|
|
146
|
-
/**
|
|
147
|
-
* Mark a backup as stable (server ran for the full stability threshold).
|
|
148
|
-
*/
|
|
149
178
|
markStable(backupId) {
|
|
150
179
|
const entry = this.manifest.backups.find(b => b.id === backupId);
|
|
151
180
|
if (entry && (entry.status === "verified" || entry.status === "unstable")) {
|
|
@@ -156,163 +185,124 @@ class BackupManager {
|
|
|
156
185
|
}
|
|
157
186
|
}
|
|
158
187
|
|
|
159
|
-
/**
|
|
160
|
-
* Set the error signature on a backup (for tracking what error this fix addressed).
|
|
161
|
-
*/
|
|
162
188
|
setErrorSignature(backupId, signature) {
|
|
163
189
|
const entry = this.manifest.backups.find(b => b.id === backupId);
|
|
164
|
-
if (entry) {
|
|
165
|
-
entry.errorSignature = signature;
|
|
166
|
-
this._saveManifest();
|
|
167
|
-
}
|
|
190
|
+
if (entry) { entry.errorSignature = signature; this._saveManifest(); }
|
|
168
191
|
}
|
|
169
192
|
|
|
170
193
|
/**
|
|
171
|
-
*
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
194
|
+
* Shutdown backup — called on graceful server shutdown.
|
|
195
|
+
*/
|
|
196
|
+
createShutdownBackup() {
|
|
197
|
+
return this.createBackup("server-shutdown");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get all backups for dashboard.
|
|
202
|
+
*/
|
|
203
|
+
getAll() {
|
|
204
|
+
return this.manifest.backups;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get rollback log for dashboard.
|
|
209
|
+
*/
|
|
210
|
+
getRollbackLog() {
|
|
211
|
+
return this.manifest.rollbackLog || [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Prune old backups per retention policy.
|
|
177
216
|
*/
|
|
178
217
|
prune() {
|
|
179
218
|
const now = Date.now();
|
|
180
|
-
const cutoff = now -
|
|
219
|
+
const cutoff = now - RETENTION_MS;
|
|
181
220
|
let pruned = 0;
|
|
182
|
-
|
|
183
|
-
// Separate backups by status
|
|
184
221
|
const toKeep = [];
|
|
185
222
|
const stableOld = [];
|
|
186
223
|
|
|
187
224
|
for (const entry of this.manifest.backups) {
|
|
188
225
|
if (entry.status === "stable") {
|
|
189
|
-
if (entry.timestamp < cutoff)
|
|
190
|
-
|
|
191
|
-
} else {
|
|
192
|
-
toKeep.push(entry);
|
|
193
|
-
}
|
|
226
|
+
if (entry.timestamp < cutoff) stableOld.push(entry);
|
|
227
|
+
else toKeep.push(entry);
|
|
194
228
|
} else {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
this._deleteBackupFiles(entry);
|
|
198
|
-
pruned++;
|
|
199
|
-
} else {
|
|
200
|
-
toKeep.push(entry);
|
|
201
|
-
}
|
|
229
|
+
if (entry.timestamp < cutoff) { this._deleteBackupFiles(entry); pruned++; }
|
|
230
|
+
else toKeep.push(entry);
|
|
202
231
|
}
|
|
203
232
|
}
|
|
204
233
|
|
|
205
|
-
// For old stable backups: keep 1 per day
|
|
206
234
|
if (stableOld.length > 0) {
|
|
207
235
|
const byDay = new Map();
|
|
208
236
|
for (const entry of stableOld) {
|
|
209
237
|
const dayKey = new Date(entry.timestamp).toISOString().slice(0, 10);
|
|
210
|
-
if (!byDay.has(dayKey))
|
|
211
|
-
byDay.set(dayKey, []);
|
|
212
|
-
}
|
|
238
|
+
if (!byDay.has(dayKey)) byDay.set(dayKey, []);
|
|
213
239
|
byDay.get(dayKey).push(entry);
|
|
214
240
|
}
|
|
215
|
-
|
|
216
241
|
for (const [, dayEntries] of byDay) {
|
|
217
|
-
// Sort by timestamp descending, keep the newest per day
|
|
218
242
|
dayEntries.sort((a, b) => b.timestamp - a.timestamp);
|
|
219
|
-
toKeep.push(dayEntries[0]);
|
|
220
|
-
for (let i = 1; i < dayEntries.length; i++) {
|
|
221
|
-
this._deleteBackupFiles(dayEntries[i]);
|
|
222
|
-
pruned++;
|
|
223
|
-
}
|
|
243
|
+
toKeep.push(dayEntries[0]);
|
|
244
|
+
for (let i = 1; i < dayEntries.length; i++) { this._deleteBackupFiles(dayEntries[i]); pruned++; }
|
|
224
245
|
}
|
|
225
246
|
}
|
|
226
247
|
|
|
227
248
|
this.manifest.backups = toKeep;
|
|
228
249
|
this._saveManifest();
|
|
229
|
-
|
|
230
|
-
if (pruned > 0) {
|
|
231
|
-
console.log(chalk.gray(` 🧹 Pruned ${pruned} old backup(s).`));
|
|
232
|
-
}
|
|
233
|
-
|
|
250
|
+
if (pruned > 0) console.log(chalk.gray(` 🧹 Pruned ${pruned} old backup(s).`));
|
|
234
251
|
return pruned;
|
|
235
252
|
}
|
|
236
253
|
|
|
237
|
-
/**
|
|
238
|
-
* Get summary stats for logging.
|
|
239
|
-
*/
|
|
240
254
|
getStats() {
|
|
241
255
|
const counts = { unstable: 0, verified: 0, stable: 0 };
|
|
242
256
|
for (const entry of this.manifest.backups) {
|
|
243
257
|
counts[entry.status] = (counts[entry.status] || 0) + 1;
|
|
244
258
|
}
|
|
245
|
-
return {
|
|
246
|
-
total: this.manifest.backups.length,
|
|
247
|
-
...counts,
|
|
248
|
-
};
|
|
259
|
+
return { total: this.manifest.backups.length, ...counts };
|
|
249
260
|
}
|
|
250
261
|
|
|
251
262
|
// -- Private --
|
|
252
263
|
|
|
253
|
-
_ensureDirs() {
|
|
254
|
-
fs.mkdirSync(this.backupsDir, { recursive: true });
|
|
255
|
-
}
|
|
264
|
+
_ensureDirs() { fs.mkdirSync(this.backupsDir, { recursive: true }); }
|
|
256
265
|
|
|
257
266
|
_loadManifest() {
|
|
258
267
|
if (fs.existsSync(this.manifestPath)) {
|
|
259
|
-
try {
|
|
260
|
-
|
|
261
|
-
} catch {
|
|
262
|
-
return { version: 1, backups: [] };
|
|
263
|
-
}
|
|
268
|
+
try { return JSON.parse(fs.readFileSync(this.manifestPath, "utf-8")); }
|
|
269
|
+
catch { return { version: 1, backups: [], rollbackLog: [] }; }
|
|
264
270
|
}
|
|
265
|
-
return { version: 1, backups: [] };
|
|
271
|
+
return { version: 1, backups: [], rollbackLog: [] };
|
|
266
272
|
}
|
|
267
273
|
|
|
268
274
|
_saveManifest() {
|
|
269
|
-
|
|
275
|
+
const tmp = this.manifestPath + ".tmp";
|
|
276
|
+
fs.writeFileSync(tmp, JSON.stringify(this.manifest, null, 2), "utf-8");
|
|
277
|
+
fs.renameSync(tmp, this.manifestPath);
|
|
270
278
|
}
|
|
271
279
|
|
|
272
280
|
_deleteBackupFiles(entry) {
|
|
273
281
|
const backupDir = path.join(this.backupsDir, entry.id);
|
|
274
282
|
if (fs.existsSync(backupDir)) {
|
|
275
|
-
for (const file of fs.readdirSync(backupDir))
|
|
276
|
-
fs.unlinkSync(path.join(backupDir, file));
|
|
277
|
-
}
|
|
283
|
+
for (const file of fs.readdirSync(backupDir)) fs.unlinkSync(path.join(backupDir, file));
|
|
278
284
|
fs.rmdirSync(backupDir);
|
|
279
285
|
}
|
|
280
286
|
}
|
|
281
287
|
|
|
282
|
-
/**
|
|
283
|
-
* Collect all files in the server/ directory for full backup.
|
|
284
|
-
* Includes: .js, .json, .sql, .db, .sqlite, .yaml, .yml, .env, .html, .css
|
|
285
|
-
* Excludes: node_modules, .git, large binaries
|
|
286
|
-
*/
|
|
287
288
|
_collectServerFiles() {
|
|
288
289
|
const serverDir = path.join(this.projectRoot, "server");
|
|
289
290
|
if (!fs.existsSync(serverDir)) return [];
|
|
290
|
-
|
|
291
291
|
const files = [];
|
|
292
292
|
const SKIP = new Set(["node_modules", ".git", ".wolverine"]);
|
|
293
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
294
293
|
|
|
295
294
|
const walk = (dir) => {
|
|
296
295
|
let entries;
|
|
297
296
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
298
|
-
|
|
299
297
|
for (const entry of entries) {
|
|
300
298
|
if (SKIP.has(entry.name)) continue;
|
|
301
|
-
|
|
302
299
|
const fullPath = path.join(dir, entry.name);
|
|
303
|
-
if (entry.isDirectory())
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
const stat = fs.statSync(fullPath);
|
|
308
|
-
if (stat.size <= MAX_FILE_SIZE) {
|
|
309
|
-
files.push(fullPath);
|
|
310
|
-
}
|
|
311
|
-
} catch {}
|
|
300
|
+
if (entry.isDirectory()) walk(fullPath);
|
|
301
|
+
else {
|
|
302
|
+
try { if (fs.statSync(fullPath).size <= 10 * 1024 * 1024) files.push(fullPath); } catch {}
|
|
312
303
|
}
|
|
313
304
|
}
|
|
314
305
|
};
|
|
315
|
-
|
|
316
306
|
walk(serverDir);
|
|
317
307
|
return files;
|
|
318
308
|
}
|
package/src/brain/brain.js
CHANGED
|
@@ -32,11 +32,11 @@ const SEED_DOCS = [
|
|
|
32
32
|
metadata: { topic: "overview" },
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
|
-
text: "Wolverine heal pipeline: crash detected → error parsed (file, line, message) → prompt injection scan (AUDIT_MODEL) → rate limit check → fast path repair (CODING_MODEL
|
|
35
|
+
text: "Wolverine heal pipeline: crash detected → error parsed (file, line, message, errorType) → prompt injection scan (AUDIT_MODEL) → rate limit check → operational fix attempt (missing_module → npm install, missing_file → create file, permission → chmod — zero AI tokens) → if operational fix doesn't apply → fast path repair (CODING_MODEL, supports both code changes AND shell commands like npm install) → if fast path fails → agent path (REASONING_MODEL with tools including bash_exec for npm install) → if agent fails → sub-agents (explore → plan → fix, fixer has bash_exec) → verify fix (syntax check + boot probe) → rollback on failure. Error types classified: missing_module, missing_file, permission, port_conflict, syntax, runtime, unknown.",
|
|
36
36
|
metadata: { topic: "heal-pipeline" },
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
|
-
text: "Wolverine backup system:
|
|
39
|
+
text: "Wolverine backup system: full server/ directory snapshots with lifecycle management. Every fix creates a backup with a reason string before patching. Status lifecycle: UNSTABLE (just created) → VERIFIED (fix passed boot probe) → STABLE (server ran 30min+ without crash). Features: rollbackTo(backupId) creates pre-rollback backup then restores files and restarts server. undoRollback() restores pre-rollback state. Hot-load: admin can load any backup as current server state from dashboard. Shutdown backup on graceful exit. Retention: unstable/verified pruned after 7 days. Stable backups older than 7 days keep 1 per day. Rollback log tracks all rollback/undo operations with timestamps and success status. Dashboard endpoints: POST /api/backups/:id/rollback, POST /api/backups/undo, POST /api/backups/:id/hotload (all require admin auth).",
|
|
40
40
|
metadata: { topic: "backup-system" },
|
|
41
41
|
},
|
|
42
42
|
{
|
|
@@ -108,7 +108,7 @@ const SEED_DOCS = [
|
|
|
108
108
|
metadata: { topic: "sub-agent-workflow" },
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
|
-
text: "Sub-agent tool restrictions (claw-code pattern): explore gets read_file/glob/grep/git. plan gets read_file/glob/grep/brain. fix gets read_file/write_file/edit_file/glob/grep. verify gets read_file/glob/grep/bash. research gets read_file/grep/web_fetch/brain. security gets read_file/glob/grep. database gets read_file/write_file/edit_file/glob/grep/bash. No agent gets tools it doesn't need.",
|
|
111
|
+
text: "Sub-agent tool restrictions (claw-code pattern): explore gets read_file/glob/grep/git. plan gets read_file/glob/grep/brain. fix gets read_file/write_file/edit_file/glob/grep/bash_exec (bash_exec for npm install, chmod, config creation — not all errors are code bugs). verify gets read_file/glob/grep/bash. research gets read_file/grep/web_fetch/brain. security gets read_file/glob/grep. database gets read_file/write_file/edit_file/glob/grep/bash. No agent gets tools it doesn't need.",
|
|
112
112
|
metadata: { topic: "sub-agent-tools" },
|
|
113
113
|
},
|
|
114
114
|
{
|
|
@@ -148,7 +148,7 @@ const SEED_DOCS = [
|
|
|
148
148
|
metadata: { topic: "npm-package" },
|
|
149
149
|
},
|
|
150
150
|
{
|
|
151
|
-
text: "Dashboard has 9 panels: Overview (stats cards + recent events), Events (live SSE stream), Performance (endpoint metrics), Analytics (memory/CPU charts, route health, response times), Command (admin chat with 3-route classifier), Backups (
|
|
151
|
+
text: "Dashboard has 9 panels: Overview (stats cards + recent events), Events (live SSE stream), Performance (endpoint metrics), Analytics (memory/CPU charts, route health, response times), Command (admin chat with 3-route classifier), Backups (full backup management: stats cards, backup list with rollback/hot-load buttons per entry, reason display, status badges, undo last rollback button, rollback log, admin IP allowlist management), Brain (vector store stats + function map), Repairs (error/resolution audit trail with tokens and cost), Tools (agent tool harness listing), Usage (token analytics by model/category/tool with USD costs).",
|
|
152
152
|
metadata: { topic: "dashboard-panels" },
|
|
153
153
|
},
|
|
154
154
|
{
|
|
@@ -195,6 +195,22 @@ const SEED_DOCS = [
|
|
|
195
195
|
text: "Secret redaction is a singleton: initRedactor(projectRoot) called once on startup, then redact(text), redactObj(obj), hasSecrets(text) available everywhere via require('../security/secret-redactor'). No need to pass redactor instances. Every outbound path auto-redacts: event logger, repair history, telemetry heartbeats, brain memories, AI calls, dashboard output. Env variable values replaced with process.env.KEY_NAME.",
|
|
196
196
|
metadata: { topic: "redaction-singleton" },
|
|
197
197
|
},
|
|
198
|
+
{
|
|
199
|
+
text: "Admin auth: two-factor gate — WOLVERINE_ADMIN_KEY (header/cookie/query) + IP allowlist. Localhost always allowed (127.0.0.1, ::1, ::ffff:127.0.0.1). Remote IPs added via WOLVERINE_ADMIN_IPS env var (comma-separated) or POST /api/admin/add-ip at runtime from dashboard. addAllowedIp(ip) adds both IPv4 and IPv4-mapped IPv6. 10 failed attempts = 5min lockout. Timing-safe key comparison. Dashboard stores key as cookie after first auth.",
|
|
200
|
+
metadata: { topic: "admin-auth" },
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
text: "Operational fix layer: before calling AI, wolverine checks for common non-code errors that can be fixed instantly with zero tokens. Pattern 1: 'Cannot find module X' (where X is a package name, not a relative path) → runs npm install X (or just npm install if package is already in package.json). Pattern 2: ENOENT on config/data files (.json, .yaml, .env, .log, etc.) → creates the missing file with sensible defaults (empty JSON {}, empty string). Pattern 3: EACCES/EPERM → chmod 755 on the file. This layer runs before the AI repair loop and handles ~30% of production crashes at zero cost.",
|
|
204
|
+
metadata: { topic: "operational-fix" },
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
text: "Error classification: error-parser.js classifies every crash into a type that guides fix strategy. Types: missing_module (Cannot find module 'X' where X is npm package), missing_file (Cannot find module './X' or ENOENT), permission (EACCES/EPERM), port_conflict (EADDRINUSE), syntax (SyntaxError), runtime (TypeError/ReferenceError/RangeError), unknown. The errorType field is available to all downstream handlers: operational fix, fast path, agent, sub-agents.",
|
|
208
|
+
metadata: { topic: "error-classification" },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
text: "Agent fix strategy table: the agent system prompt includes a decision table mapping error patterns to correct fix actions. Cannot find module 'X' (package) → bash_exec: npm install X. Cannot find module './X' (local) → edit_file: fix require path. ENOENT → write_file: create missing file. EACCES → bash_exec: chmod. EADDRINUSE → bash_exec: kill process. SyntaxError → edit_file: fix code. TypeError → edit_file: fix logic. MODULE_NOT_FOUND + node_modules → bash_exec: rm -rf node_modules && npm install. The fast path AI response format now supports both 'changes' (code edits) and 'commands' (shell commands like npm install). Dangerous commands blocked: rm -rf /, format, mkfs.",
|
|
212
|
+
metadata: { topic: "agent-fix-strategy" },
|
|
213
|
+
},
|
|
198
214
|
];
|
|
199
215
|
|
|
200
216
|
class Brain {
|
package/src/core/ai-client.js
CHANGED
|
@@ -359,7 +359,11 @@ ${stackTrace}
|
|
|
359
359
|
|
|
360
360
|
## Instructions
|
|
361
361
|
1. Identify the root cause of the error.
|
|
362
|
-
2.
|
|
362
|
+
2. Not all errors are code bugs. Choose the correct fix type:
|
|
363
|
+
- "Cannot find module 'X'" (not starting with ./ or ../) = missing npm package → use "commands" to npm install
|
|
364
|
+
- "Cannot find module './X'" = wrong import path → use "changes" to fix the require/import
|
|
365
|
+
- "ENOENT" = missing file → use "commands" to create it, or "changes" to fix the path
|
|
366
|
+
- SyntaxError/TypeError/ReferenceError = code bug → use "changes" to fix the code
|
|
363
367
|
3. Respond with ONLY valid JSON in this exact format:
|
|
364
368
|
|
|
365
369
|
{
|
|
@@ -370,8 +374,13 @@ ${stackTrace}
|
|
|
370
374
|
"old": "the exact lines to replace (copy verbatim from the source)",
|
|
371
375
|
"new": "the replacement lines"
|
|
372
376
|
}
|
|
373
|
-
]
|
|
374
|
-
|
|
377
|
+
],
|
|
378
|
+
"commands": ["npm install cors", "mkdir -p server/config"]
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
"commands" is an array of shell commands to run (optional, use for npm install, file creation, etc).
|
|
382
|
+
"changes" is for code edits (optional, use for actual code fixes).
|
|
383
|
+
Include both if needed, or just one.`;
|
|
375
384
|
|
|
376
385
|
const result = await aiCall({ model, systemPrompt, userPrompt, maxTokens: 2048, category: "heal" });
|
|
377
386
|
const content = result.content;
|
package/src/core/error-parser.js
CHANGED
|
@@ -75,13 +75,52 @@ function parseError(stderr) {
|
|
|
75
75
|
filePath = path.resolve(filePath);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Classify the error type — helps the heal pipeline choose the right strategy
|
|
79
|
+
const errorType = classifyError(errorMessage, stderr);
|
|
80
|
+
|
|
78
81
|
return {
|
|
79
82
|
filePath,
|
|
80
83
|
line,
|
|
81
84
|
column,
|
|
82
85
|
errorMessage,
|
|
83
86
|
stackTrace,
|
|
87
|
+
errorType,
|
|
84
88
|
};
|
|
85
89
|
}
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Classify an error into categories to guide fix strategy.
|
|
93
|
+
* Returns: "missing_module" | "missing_file" | "permission" | "port_conflict" | "syntax" | "runtime" | "unknown"
|
|
94
|
+
*/
|
|
95
|
+
function classifyError(errorMessage, fullStderr) {
|
|
96
|
+
const msg = (errorMessage || "").toLowerCase();
|
|
97
|
+
const full = (fullStderr || "").toLowerCase();
|
|
98
|
+
|
|
99
|
+
// Missing npm package: Cannot find module 'cors' (not a relative path)
|
|
100
|
+
if (/cannot find module '(?![./\\])/.test(msg) || /module_not_found/.test(full)) {
|
|
101
|
+
return "missing_module";
|
|
102
|
+
}
|
|
103
|
+
// Missing local file: Cannot find module './routes/api'
|
|
104
|
+
if (/cannot find module '[./\\]/.test(msg) || /enoent/.test(msg)) {
|
|
105
|
+
return "missing_file";
|
|
106
|
+
}
|
|
107
|
+
// Permission denied
|
|
108
|
+
if (/eacces|eperm/.test(msg)) {
|
|
109
|
+
return "permission";
|
|
110
|
+
}
|
|
111
|
+
// Port already in use
|
|
112
|
+
if (/eaddrinuse/.test(msg)) {
|
|
113
|
+
return "port_conflict";
|
|
114
|
+
}
|
|
115
|
+
// Syntax error
|
|
116
|
+
if (/syntaxerror|unexpected token|unexpected end/.test(msg)) {
|
|
117
|
+
return "syntax";
|
|
118
|
+
}
|
|
119
|
+
// Runtime errors (TypeError, ReferenceError, RangeError)
|
|
120
|
+
if (/typeerror|referenceerror|rangeerror/.test(msg)) {
|
|
121
|
+
return "runtime";
|
|
122
|
+
}
|
|
123
|
+
return "unknown";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { parseError, classifyError };
|
package/src/core/runner.js
CHANGED
|
@@ -235,8 +235,23 @@ class WolverineRunner {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Graceful shutdown — backup, stop subsystems, kill child cleanly.
|
|
240
|
+
* Prevents wolverine from treating shutdown as a crash.
|
|
241
|
+
*/
|
|
238
242
|
stop() {
|
|
243
|
+
if (!this.running) return; // prevent double-stop
|
|
239
244
|
this.running = false;
|
|
245
|
+
this._shuttingDown = true;
|
|
246
|
+
|
|
247
|
+
console.log(chalk.yellow("\n 🔒 Graceful shutdown..."));
|
|
248
|
+
|
|
249
|
+
// Create shutdown backup
|
|
250
|
+
try {
|
|
251
|
+
this.backupManager.createShutdownBackup();
|
|
252
|
+
} catch {}
|
|
253
|
+
|
|
254
|
+
// Stop all monitors (prevents restart triggers during shutdown)
|
|
240
255
|
this._clearStabilityTimer();
|
|
241
256
|
this.healthMonitor.stop();
|
|
242
257
|
this.perfMonitor.stop();
|
|
@@ -247,10 +262,16 @@ class WolverineRunner {
|
|
|
247
262
|
this.tokenTracker.save();
|
|
248
263
|
this.dashboard.stop();
|
|
249
264
|
|
|
250
|
-
this.logger.info(EVENT_TYPES.PROCESS_STOP, "Wolverine stopped");
|
|
265
|
+
this.logger.info(EVENT_TYPES.PROCESS_STOP, "Wolverine stopped (graceful shutdown)");
|
|
251
266
|
|
|
267
|
+
// Kill child — remove exit listener first so it doesn't trigger heal
|
|
252
268
|
if (this.child) {
|
|
269
|
+
this.child.removeAllListeners("exit");
|
|
253
270
|
this.child.kill("SIGTERM");
|
|
271
|
+
// Force kill after 3s if it doesn't respond
|
|
272
|
+
setTimeout(() => {
|
|
273
|
+
try { if (this.child) this.child.kill("SIGKILL"); } catch {}
|
|
274
|
+
}, 3000);
|
|
254
275
|
this.child = null;
|
|
255
276
|
}
|
|
256
277
|
}
|
package/src/core/wolverine.js
CHANGED
|
@@ -73,6 +73,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
73
73
|
console.log(chalk.cyan(` File: ${parsed.filePath}`));
|
|
74
74
|
console.log(chalk.cyan(` Line: ${parsed.line || "unknown"}`));
|
|
75
75
|
console.log(chalk.cyan(` Error: ${parsed.errorMessage}`));
|
|
76
|
+
console.log(chalk.cyan(` Type: ${parsed.errorType || "unknown"}`));
|
|
76
77
|
|
|
77
78
|
// 3. Rate limit check
|
|
78
79
|
const rateCheck = rateLimiter.check(errorSignature);
|
|
@@ -111,6 +112,24 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
// 4c. Pre-heal operational fix — detect common non-code errors
|
|
116
|
+
// Some crashes aren't code bugs (missing npm packages, missing config files).
|
|
117
|
+
// Fix these directly without wasting AI tokens.
|
|
118
|
+
const opsFix = await tryOperationalFix(parsed, cwd, logger);
|
|
119
|
+
if (opsFix.fixed) {
|
|
120
|
+
console.log(chalk.green(` ⚡ Operational fix applied: ${opsFix.action}`));
|
|
121
|
+
if (logger) logger.info(EVENT_TYPES.HEAL_SUCCESS, `Operational fix: ${opsFix.action}`, { action: opsFix.action });
|
|
122
|
+
if (repairHistory) {
|
|
123
|
+
repairHistory.record({
|
|
124
|
+
error: parsed.errorMessage, file: parsed.filePath, line: parsed.line,
|
|
125
|
+
resolution: opsFix.action, success: true, mode: "operational",
|
|
126
|
+
model: "none", tokens: 0, cost: 0, iteration: 0,
|
|
127
|
+
duration: Date.now() - healStartTime, filesModified: [],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return { healed: true, explanation: opsFix.action, mode: "operational" };
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
// 5. Read the source file + get brain context
|
|
115
134
|
const sourceCode = sandbox.readFile(parsed.filePath);
|
|
116
135
|
|
|
@@ -164,7 +183,7 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
164
183
|
|
|
165
184
|
let result;
|
|
166
185
|
if (iteration === 1) {
|
|
167
|
-
// Fast path — CODING_MODEL, single file
|
|
186
|
+
// Fast path — CODING_MODEL, single file + optional commands
|
|
168
187
|
console.log(chalk.yellow(` 🧠 Fast path (${getModel("coding")})...`));
|
|
169
188
|
try {
|
|
170
189
|
const repair = await requestRepair({
|
|
@@ -173,13 +192,35 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
173
192
|
});
|
|
174
193
|
rateLimiter.record(errorSignature);
|
|
175
194
|
|
|
176
|
-
|
|
177
|
-
if (
|
|
195
|
+
// Execute shell commands first (npm install, mkdir, etc.)
|
|
196
|
+
if (repair.commands && Array.isArray(repair.commands)) {
|
|
197
|
+
const { execSync } = require("child_process");
|
|
198
|
+
for (const cmd of repair.commands) {
|
|
199
|
+
// Block dangerous commands
|
|
200
|
+
if (/rm\s+-rf\s+[/\\]|format\s+c:|mkfs/i.test(cmd)) {
|
|
201
|
+
console.log(chalk.red(` 🛡️ Blocked dangerous command: ${cmd}`));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
console.log(chalk.blue(` ⚡ Running: ${cmd}`));
|
|
205
|
+
try {
|
|
206
|
+
execSync(cmd, { cwd, stdio: "pipe", timeout: 60000 });
|
|
207
|
+
console.log(chalk.green(` ✅ Command succeeded: ${cmd}`));
|
|
208
|
+
} catch (cmdErr) {
|
|
209
|
+
console.log(chalk.yellow(` ⚠️ Command failed: ${cmd} — ${cmdErr.message?.slice(0, 80)}`));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
178
213
|
|
|
179
|
-
|
|
180
|
-
if (
|
|
214
|
+
// Apply code changes (if any)
|
|
215
|
+
if (repair.changes && repair.changes.length > 0) {
|
|
216
|
+
const sandboxCheck = sandbox.validateChanges(repair.changes);
|
|
217
|
+
if (!sandboxCheck.valid) throw new Error("Changes outside sandbox");
|
|
181
218
|
|
|
182
|
-
|
|
219
|
+
const patchResults = applyPatch(repair.changes, cwd, sandbox);
|
|
220
|
+
if (!patchResults.every(r => r.success)) throw new Error("Patch failed");
|
|
221
|
+
|
|
222
|
+
for (const r of patchResults) console.log(chalk.green(` ✅ Patched: ${r.file}`));
|
|
223
|
+
}
|
|
183
224
|
|
|
184
225
|
const verification = await verifyFix(parsed.filePath, cwd, errorSignature);
|
|
185
226
|
if (verification.verified) {
|
|
@@ -287,4 +328,82 @@ async function heal({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager
|
|
|
287
328
|
return { healed: false, explanation: goalResult.explanation };
|
|
288
329
|
}
|
|
289
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Try to fix common operational errors without AI.
|
|
333
|
+
* Returns { fixed: boolean, action: string }
|
|
334
|
+
*/
|
|
335
|
+
async function tryOperationalFix(parsed, cwd, logger) {
|
|
336
|
+
const { execSync } = require("child_process");
|
|
337
|
+
const msg = parsed.errorMessage || "";
|
|
338
|
+
|
|
339
|
+
// Pattern 1: Cannot find module 'X' — missing npm package
|
|
340
|
+
const missingModule = msg.match(/Cannot find module '([^']+)'/);
|
|
341
|
+
if (missingModule) {
|
|
342
|
+
const moduleName = missingModule[1];
|
|
343
|
+
|
|
344
|
+
// Only npm install if it's a package name (not a relative/absolute path)
|
|
345
|
+
if (!moduleName.startsWith(".") && !moduleName.startsWith("/") && !moduleName.startsWith("\\")) {
|
|
346
|
+
// Check if it's already in package.json but not installed
|
|
347
|
+
const fs = require("fs");
|
|
348
|
+
const path = require("path");
|
|
349
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
350
|
+
let isInPkg = false;
|
|
351
|
+
try {
|
|
352
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
353
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
354
|
+
isInPkg = !!allDeps[moduleName];
|
|
355
|
+
} catch {}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const cmd = isInPkg ? "npm install" : `npm install ${moduleName}`;
|
|
359
|
+
console.log(chalk.blue(` 📦 Missing module '${moduleName}' — running: ${cmd}`));
|
|
360
|
+
if (logger) logger.info("heal.ops", `Running: ${cmd}`, { module: moduleName });
|
|
361
|
+
execSync(cmd, { cwd, stdio: "pipe", timeout: 60000 });
|
|
362
|
+
return { fixed: true, action: `Installed missing module '${moduleName}' via: ${cmd}` };
|
|
363
|
+
} catch (e) {
|
|
364
|
+
console.log(chalk.yellow(` ⚠️ npm install failed: ${e.message?.slice(0, 100)}`));
|
|
365
|
+
// Fall through to AI repair
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Pattern 2: ENOENT on config/data files the server expects
|
|
371
|
+
const enoent = msg.match(/ENOENT.*?'([^']+)'/);
|
|
372
|
+
if (enoent) {
|
|
373
|
+
const missingFile = enoent[1];
|
|
374
|
+
const fs = require("fs");
|
|
375
|
+
const path = require("path");
|
|
376
|
+
|
|
377
|
+
// Only auto-create if it's inside the project and looks like a config/data file
|
|
378
|
+
const rel = path.relative(cwd, missingFile).replace(/\\/g, "/");
|
|
379
|
+
if (!rel.startsWith("..") && /\.(json|yaml|yml|toml|ini|conf|cfg|env|log|txt|csv|db|sqlite)$/i.test(missingFile)) {
|
|
380
|
+
try {
|
|
381
|
+
fs.mkdirSync(path.dirname(missingFile), { recursive: true });
|
|
382
|
+
// Create empty file or sensible default
|
|
383
|
+
const ext = path.extname(missingFile).toLowerCase();
|
|
384
|
+
const defaults = { ".json": "{}", ".yaml": "", ".yml": "", ".log": "", ".txt": "", ".csv": "", ".env": "" };
|
|
385
|
+
fs.writeFileSync(missingFile, defaults[ext] || "", "utf-8");
|
|
386
|
+
console.log(chalk.blue(` 📄 Created missing file: ${rel}`));
|
|
387
|
+
return { fixed: true, action: `Created missing file: ${rel}` };
|
|
388
|
+
} catch {}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Pattern 3: EACCES/EPERM permission errors
|
|
393
|
+
const permErr = /EACCES|EPERM/.test(msg);
|
|
394
|
+
if (permErr) {
|
|
395
|
+
const permFile = msg.match(/(?:EACCES|EPERM).*?'([^']+)'/);
|
|
396
|
+
if (permFile) {
|
|
397
|
+
try {
|
|
398
|
+
const fs = require("fs");
|
|
399
|
+
fs.chmodSync(permFile[1], 0o755);
|
|
400
|
+
console.log(chalk.blue(` 🔑 Fixed permissions on: ${permFile[1]}`));
|
|
401
|
+
return { fixed: true, action: `Fixed permissions (chmod 755) on: ${permFile[1]}` };
|
|
402
|
+
} catch {}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { fixed: false };
|
|
407
|
+
}
|
|
408
|
+
|
|
290
409
|
module.exports = { heal };
|
package/src/dashboard/server.js
CHANGED
|
@@ -68,6 +68,10 @@ class DashboardServer {
|
|
|
68
68
|
if (req.url === "/api/usage/history") return this._handleUsageHistory(req, res);
|
|
69
69
|
if (req.url === "/api/auth/verify" && req.method === "POST") return this._handleAuthVerify(req, res);
|
|
70
70
|
if (req.url === "/api/command" && req.method === "POST") return this._handleCommand(req, res);
|
|
71
|
+
if (req.url.startsWith("/api/backups/") && req.url.endsWith("/rollback") && req.method === "POST") return this._handleRollback(req, res);
|
|
72
|
+
if (req.url === "/api/backups/undo" && req.method === "POST") return this._handleUndoRollback(req, res);
|
|
73
|
+
if (req.url.startsWith("/api/backups/") && req.url.endsWith("/hotload") && req.method === "POST") return this._handleHotload(req, res);
|
|
74
|
+
if (req.url === "/api/admin/add-ip" && req.method === "POST") return this._handleAddIp(req, res);
|
|
71
75
|
if (req.url === "/api/chat/clear" && req.method === "POST") {
|
|
72
76
|
this._chatHistory = [];
|
|
73
77
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -432,7 +436,7 @@ ${existingRoutes || "(none)"}`,
|
|
|
432
436
|
|
|
433
437
|
// Backup entire server/ before making changes
|
|
434
438
|
if (this.runner && this.runner.backupManager) {
|
|
435
|
-
const bid = this.runner.backupManager.createBackup(
|
|
439
|
+
const bid = this.runner.backupManager.createBackup("pre-edit: " + safeCommand.slice(0, 80));
|
|
436
440
|
this.runner.backupManager.markVerified(bid);
|
|
437
441
|
console.log(chalk.gray(` 💾 Backup created: ${bid}`));
|
|
438
442
|
}
|
|
@@ -875,7 +879,132 @@ ${context ? "\nBrain:\n" + context : ""}`,
|
|
|
875
879
|
|
|
876
880
|
_handleBackups(req, res) {
|
|
877
881
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
878
|
-
|
|
882
|
+
if (this.backupManager) {
|
|
883
|
+
res.end(JSON.stringify({
|
|
884
|
+
backups: this.backupManager.getAll(),
|
|
885
|
+
rollbackLog: this.backupManager.getRollbackLog(),
|
|
886
|
+
stats: this.backupManager.getStats(),
|
|
887
|
+
}));
|
|
888
|
+
} else {
|
|
889
|
+
res.end(JSON.stringify({ backups: [], rollbackLog: [], stats: {} }));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ── Backup Management Endpoints ──
|
|
894
|
+
|
|
895
|
+
_handleRollback(req, res) {
|
|
896
|
+
const authResult = this.auth.validate(req);
|
|
897
|
+
if (!authResult.authorized) {
|
|
898
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
899
|
+
res.end(JSON.stringify({ error: "Forbidden", reason: authResult.reason }));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const backupId = req.url.replace("/api/backups/", "").replace("/rollback", "");
|
|
904
|
+
if (!this.backupManager) {
|
|
905
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
906
|
+
res.end(JSON.stringify({ error: "Backup manager not available" }));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const result = this.backupManager.rollbackTo(backupId);
|
|
911
|
+
if (result.success && this.runner) {
|
|
912
|
+
console.log(chalk.blue(" 🔄 Restarting server after rollback..."));
|
|
913
|
+
this.runner.restart();
|
|
914
|
+
}
|
|
915
|
+
if (this.logger) {
|
|
916
|
+
this.logger.info("backup.rollback", `Rolled back to ${backupId}`, { backupId, preRollbackId: result.preRollbackId, success: result.success });
|
|
917
|
+
}
|
|
918
|
+
this._broadcast({ type: "backup.rollback", timestamp: Date.now(), message: `Rolled back to ${backupId}`, severity: "warn" });
|
|
919
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
920
|
+
res.end(JSON.stringify(result));
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
_handleUndoRollback(req, res) {
|
|
924
|
+
const authResult = this.auth.validate(req);
|
|
925
|
+
if (!authResult.authorized) {
|
|
926
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
927
|
+
res.end(JSON.stringify({ error: "Forbidden", reason: authResult.reason }));
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (!this.backupManager) {
|
|
932
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
933
|
+
res.end(JSON.stringify({ error: "Backup manager not available" }));
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const result = this.backupManager.undoRollback();
|
|
938
|
+
if (result.success && this.runner) {
|
|
939
|
+
console.log(chalk.blue(" 🔄 Restarting server after undo rollback..."));
|
|
940
|
+
this.runner.restart();
|
|
941
|
+
}
|
|
942
|
+
if (this.logger) {
|
|
943
|
+
this.logger.info("backup.undo", "Undo rollback", { success: result.success });
|
|
944
|
+
}
|
|
945
|
+
this._broadcast({ type: "backup.undo", timestamp: Date.now(), message: "Undo rollback executed", severity: "warn" });
|
|
946
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
947
|
+
res.end(JSON.stringify(result));
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
_handleHotload(req, res) {
|
|
951
|
+
const authResult = this.auth.validate(req);
|
|
952
|
+
if (!authResult.authorized) {
|
|
953
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
954
|
+
res.end(JSON.stringify({ error: "Forbidden", reason: authResult.reason }));
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const backupId = req.url.replace("/api/backups/", "").replace("/hotload", "");
|
|
959
|
+
if (!this.backupManager) {
|
|
960
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
961
|
+
res.end(JSON.stringify({ error: "Backup manager not available" }));
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Hot-load: rollback to the backup state and restart immediately
|
|
966
|
+
const entry = this.backupManager.manifest.backups.find(b => b.id === backupId);
|
|
967
|
+
if (!entry) {
|
|
968
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
969
|
+
res.end(JSON.stringify({ error: "Backup not found" }));
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const result = this.backupManager.rollbackTo(backupId);
|
|
974
|
+
if (result.success && this.runner) {
|
|
975
|
+
console.log(chalk.blue(` ⚡ Hot-loading backup ${backupId}...`));
|
|
976
|
+
this.runner.restart();
|
|
977
|
+
}
|
|
978
|
+
if (this.logger) {
|
|
979
|
+
this.logger.info("backup.hotload", `Hot-loaded backup ${backupId}`, { backupId, success: result.success });
|
|
980
|
+
}
|
|
981
|
+
this._broadcast({ type: "backup.hotload", timestamp: Date.now(), message: `Hot-loaded backup ${backupId}`, severity: "info" });
|
|
982
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
983
|
+
res.end(JSON.stringify({ ...result, hotloaded: true }));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
_handleAddIp(req, res) {
|
|
987
|
+
const authResult = this.auth.validate(req);
|
|
988
|
+
if (!authResult.authorized) {
|
|
989
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
990
|
+
res.end(JSON.stringify({ error: "Forbidden", reason: authResult.reason }));
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
this._readBody(req, (body) => {
|
|
995
|
+
const ip = (body.ip || "").trim();
|
|
996
|
+
if (!ip || !/^[\d.:a-fA-F]+$/.test(ip)) {
|
|
997
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
998
|
+
res.end(JSON.stringify({ error: "Invalid IP address" }));
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
this.auth.addAllowedIp(ip);
|
|
1002
|
+
if (this.logger) {
|
|
1003
|
+
this.logger.info("admin.add_ip", `Added allowed IP: ${ip}`, { ip });
|
|
1004
|
+
}
|
|
1005
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1006
|
+
res.end(JSON.stringify({ success: true, ip, allowedIps: [...this.auth._allowedIps] }));
|
|
1007
|
+
});
|
|
879
1008
|
}
|
|
880
1009
|
|
|
881
1010
|
_handleUsage(req, res) {
|
|
@@ -1119,7 +1248,30 @@ main{overflow-y:auto;padding:24px}
|
|
|
1119
1248
|
</div>
|
|
1120
1249
|
</div>
|
|
1121
1250
|
</div>
|
|
1122
|
-
<div class="panel" id="p-backups"
|
|
1251
|
+
<div class="panel" id="p-backups">
|
|
1252
|
+
<div class="stats" style="grid-template-columns:repeat(4,1fr)">
|
|
1253
|
+
<div class="stat-card up"><div class="stat-val" id="bk-total">0</div><div class="stat-lbl">Total Backups</div></div>
|
|
1254
|
+
<div class="stat-card heal"><div class="stat-val" id="bk-stable">0</div><div class="stat-lbl">Stable</div></div>
|
|
1255
|
+
<div class="stat-card brain"><div class="stat-val" id="bk-verified">0</div><div class="stat-lbl">Verified</div></div>
|
|
1256
|
+
<div class="stat-card roll"><div class="stat-val" id="bk-rollbacks">0</div><div class="stat-lbl">Rollbacks</div></div>
|
|
1257
|
+
</div>
|
|
1258
|
+
<div class="card" style="margin-top:16px">
|
|
1259
|
+
<h3 style="display:flex;justify-content:space-between;align-items:center">Backup Management <button onclick="undoRollback()" id="undo-btn" style="display:none;background:var(--yellow);color:#000;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-size:.75rem">↩ Undo Last Rollback</button></h3>
|
|
1260
|
+
<div id="bk-list"><div class="empty">No backups</div></div>
|
|
1261
|
+
</div>
|
|
1262
|
+
<div class="card" style="margin-top:16px">
|
|
1263
|
+
<h3>Rollback Log</h3>
|
|
1264
|
+
<div id="bk-rlog"><div class="empty">No rollbacks performed</div></div>
|
|
1265
|
+
</div>
|
|
1266
|
+
<div class="card" style="margin-top:16px">
|
|
1267
|
+
<h3>Admin IP Allowlist</h3>
|
|
1268
|
+
<div style="display:flex;gap:8px;margin-bottom:12px">
|
|
1269
|
+
<input type="text" id="ip-input" placeholder="e.g. 203.0.113.42" style="flex:1;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.85rem">
|
|
1270
|
+
<button onclick="addAdminIp()" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 18px;cursor:pointer;font-size:.85rem">Add IP</button>
|
|
1271
|
+
</div>
|
|
1272
|
+
<div id="ip-list"><div class="empty">Only localhost by default</div></div>
|
|
1273
|
+
</div>
|
|
1274
|
+
</div>
|
|
1123
1275
|
<div class="panel" id="p-brain">
|
|
1124
1276
|
<div class="card"><h3>Brain Statistics</h3><div class="ns-grid" id="br-ns"></div></div>
|
|
1125
1277
|
<div class="card" style="margin-top:16px"><h3>Function Map</h3><div id="br-fmap"><div class="empty">Loading...</div></div></div>
|
|
@@ -1252,8 +1404,24 @@ async function refresh(){
|
|
|
1252
1404
|
if(brn.functionMap){const fm=brn.functionMap;$('br-fmap').innerHTML=['Routes:'+fm.routes,'Functions:'+fm.functions,'Classes:'+fm.classes,'Files:'+fm.files].map(x=>'<div class="mrow"><span>'+x.split(':')[0]+'</span><span class="vals"><b>'+x.split(':')[1]+'</b></span></div>').join('');}
|
|
1253
1405
|
const eps=Object.entries(mr);const mh=eps.length?eps.map(([p,m])=>'<div class="mrow"><span class="ep">'+esc(p)+'</span><span class="vals"><b>'+m.avgResponseMs+'ms</b> avg · '+m.requestsPerMin+' req/min · '+m.errorRate+'% err</span></div>').join(''):'<div class="empty">No traffic yet</div>';
|
|
1254
1406
|
$('ov-metrics').innerHTML=mh;$('perf-list').innerHTML=mh;
|
|
1255
|
-
const
|
|
1256
|
-
$('
|
|
1407
|
+
const bkData=br2;const bkList=bkData.backups||[];const bkRlog=bkData.rollbackLog||[];const bkStats=bkData.stats||{};
|
|
1408
|
+
$('bk-total').textContent=bkStats.total||0;$('bk-stable').textContent=bkStats.stable||0;$('bk-verified').textContent=bkStats.verified||0;$('bk-rollbacks').textContent=bkRlog.length;
|
|
1409
|
+
if(bkRlog.length>0)$('undo-btn').style.display='inline-block';else $('undo-btn').style.display='none';
|
|
1410
|
+
const ovBh=bkList.length?bkList.slice(-10).reverse().map(b=>'<div class="mrow"><span>'+new Date(b.timestamp).toLocaleString()+'</span><span><span class="badge badge-'+b.status+'">'+b.status+'</span> '+b.fileCount+' file(s)</span></div>').join(''):'<div class="empty">No backups</div>';
|
|
1411
|
+
$('ov-backups').innerHTML=ovBh;
|
|
1412
|
+
const bkFull=bkList.length?bkList.slice(-20).reverse().map(b=>{
|
|
1413
|
+
const age=Math.round((Date.now()-b.timestamp)/60000);const ageStr=age<60?age+'m ago':Math.round(age/60)+'h ago';
|
|
1414
|
+
const reason=b.reason?esc(b.reason):'<i>no reason</i>';
|
|
1415
|
+
const btns=adminKey?'<span style="margin-left:auto;display:flex;gap:4px"><button onclick="rollbackTo(\''+b.id+'\')" style="background:var(--yellow);color:#000;border:none;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:.7rem" title="Rollback to this state">↩ Rollback</button><button onclick="hotload(\''+b.id+'\')" style="background:var(--accent);color:#fff;border:none;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:.7rem" title="Hot-load this backup as current server">⚡ Hot-load</button></span>':'';
|
|
1416
|
+
return '<div class="mrow" style="flex-wrap:wrap;gap:6px"><span style="min-width:140px">'+new Date(b.timestamp).toLocaleString()+'</span><span class="badge badge-'+b.status+'">'+b.status+'</span><span style="color:var(--text2);font-size:.75rem">'+reason+'</span><span style="color:var(--text2);font-size:.7rem">'+b.fileCount+' files · '+ageStr+'</span>'+btns+'</div>';
|
|
1417
|
+
}).join(''):'<div class="empty">No backups</div>';
|
|
1418
|
+
$('bk-list').innerHTML=bkFull;
|
|
1419
|
+
const rlogHtml=bkRlog.length?bkRlog.slice(-10).reverse().map(r=>{
|
|
1420
|
+
const action=r.action==='undo'?'<span style="color:var(--yellow)">UNDO</span>':'<span style="color:var(--accent)">ROLLBACK</span>';
|
|
1421
|
+
const status=r.success?'<span style="color:var(--green)">OK</span>':'<span style="color:var(--red)">FAIL</span>';
|
|
1422
|
+
return '<div class="mrow"><span>'+new Date(r.timestamp).toLocaleString()+'</span><span>'+action+' → '+esc(r.restoredBackupId||'')+'</span><span>'+status+'</span></div>';
|
|
1423
|
+
}).join(''):'<div class="empty">No rollbacks performed</div>';
|
|
1424
|
+
$('bk-rlog').innerHTML=rlogHtml;
|
|
1257
1425
|
// Usage analytics
|
|
1258
1426
|
if(usage&&usage.session){
|
|
1259
1427
|
$('u-total').textContent=(usage.session.totalTokens||0).toLocaleString();
|
|
@@ -1406,6 +1574,57 @@ const tools=[{n:'read_file',d:'Read file with offset/limit',c:'file'},{n:'write_
|
|
|
1406
1574
|
const cc={file:'var(--blue)',shell:'var(--yellow)',web:'var(--purple)',ctrl:'var(--green)'};
|
|
1407
1575
|
$('tool-list').innerHTML=tools.map(t=>'<div class="mrow"><span class="ep" style="color:'+cc[t.c]+'">'+t.n+'</span><span class="vals">'+esc(t.d)+'</span></div>').join('');
|
|
1408
1576
|
|
|
1577
|
+
async function rollbackTo(id){
|
|
1578
|
+
if(!adminKey){alert('Authenticate first');return;}
|
|
1579
|
+
if(!confirm('Rollback to backup '+id+'? This will restore all server files to that state and restart.'))return;
|
|
1580
|
+
try{
|
|
1581
|
+
const r=await fetch(B+'/api/backups/'+id+'/rollback',{method:'POST',headers:{'Content-Type':'application/json','X-Admin-Key':adminKey}});
|
|
1582
|
+
const d=await r.json();
|
|
1583
|
+
if(d.success){alert('Rolled back to '+id+'. Server restarting... Pre-rollback backup: '+d.preRollbackId);}
|
|
1584
|
+
else{alert('Rollback failed: '+(d.error||'unknown'));}
|
|
1585
|
+
refresh();
|
|
1586
|
+
}catch(e){alert('Error: '+e.message);}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
async function hotload(id){
|
|
1590
|
+
if(!adminKey){alert('Authenticate first');return;}
|
|
1591
|
+
if(!confirm('Hot-load backup '+id+' as current server state? This will replace all server files and restart.'))return;
|
|
1592
|
+
try{
|
|
1593
|
+
const r=await fetch(B+'/api/backups/'+id+'/hotload',{method:'POST',headers:{'Content-Type':'application/json','X-Admin-Key':adminKey}});
|
|
1594
|
+
const d=await r.json();
|
|
1595
|
+
if(d.success){alert('Hot-loaded '+id+'. Server restarting...');}
|
|
1596
|
+
else{alert('Hot-load failed: '+(d.error||'unknown'));}
|
|
1597
|
+
refresh();
|
|
1598
|
+
}catch(e){alert('Error: '+e.message);}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function undoRollback(){
|
|
1602
|
+
if(!adminKey){alert('Authenticate first');return;}
|
|
1603
|
+
if(!confirm('Undo the last rollback? This will restore the pre-rollback state and restart.'))return;
|
|
1604
|
+
try{
|
|
1605
|
+
const r=await fetch(B+'/api/backups/undo',{method:'POST',headers:{'Content-Type':'application/json','X-Admin-Key':adminKey}});
|
|
1606
|
+
const d=await r.json();
|
|
1607
|
+
if(d.success){alert('Rollback undone. Server restarting...');}
|
|
1608
|
+
else{alert('Undo failed: '+(d.error||'unknown'));}
|
|
1609
|
+
refresh();
|
|
1610
|
+
}catch(e){alert('Error: '+e.message);}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
async function addAdminIp(){
|
|
1614
|
+
if(!adminKey){alert('Authenticate first');return;}
|
|
1615
|
+
const ip=$('ip-input').value.trim();
|
|
1616
|
+
if(!ip){alert('Enter an IP address');return;}
|
|
1617
|
+
try{
|
|
1618
|
+
const r=await fetch(B+'/api/admin/add-ip',{method:'POST',headers:{'Content-Type':'application/json','X-Admin-Key':adminKey},body:JSON.stringify({ip})});
|
|
1619
|
+
const d=await r.json();
|
|
1620
|
+
if(d.success){
|
|
1621
|
+
$('ip-input').value='';
|
|
1622
|
+
$('ip-list').innerHTML=(d.allowedIps||[]).map(x=>'<div class="mrow"><span>'+esc(x)+'</span></div>').join('');
|
|
1623
|
+
alert('IP '+ip+' added to allowlist');
|
|
1624
|
+
}else{alert('Failed: '+(d.error||'unknown'));}
|
|
1625
|
+
}catch(e){alert('Error: '+e.message);}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1409
1628
|
refresh();setInterval(refresh,5000);
|
|
1410
1629
|
fetch(B+'/api/events').then(r=>r.json()).then(evs=>evs.forEach(addEvent)).catch(()=>{});
|
|
1411
1630
|
</script></body></html>`;
|
|
@@ -22,9 +22,21 @@ const LOCALHOST_IPS = new Set([
|
|
|
22
22
|
class AdminAuth {
|
|
23
23
|
constructor() {
|
|
24
24
|
this.adminKey = process.env.WOLVERINE_ADMIN_KEY || null;
|
|
25
|
-
|
|
25
|
+
// Allowed IPs: localhost always + any IPs in WOLVERINE_ADMIN_IPS (comma-separated)
|
|
26
|
+
this._allowedIps = new Set(LOCALHOST_IPS);
|
|
27
|
+
if (process.env.WOLVERINE_ADMIN_IPS) {
|
|
28
|
+
for (const ip of process.env.WOLVERINE_ADMIN_IPS.split(",")) {
|
|
29
|
+
this._allowedIps.add(ip.trim());
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
this._failedAttempts = new Map();
|
|
26
33
|
this._maxFailedAttempts = 10;
|
|
27
|
-
this._lockoutMs = 300000;
|
|
34
|
+
this._lockoutMs = 300000;
|
|
35
|
+
|
|
36
|
+
if (!this.adminKey) {
|
|
37
|
+
console.log("\x1b[33m ⚠️ No WOLVERINE_ADMIN_KEY set — generate one:\x1b[0m");
|
|
38
|
+
console.log("\x1b[90m node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"\x1b[0m");
|
|
39
|
+
}
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
/**
|
|
@@ -91,9 +103,16 @@ class AdminAuth {
|
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
_isLocalhost(ip) {
|
|
94
|
-
// Normalize IPv6-mapped IPv4
|
|
95
106
|
const normalized = ip.replace(/^::ffff:/, "");
|
|
96
|
-
return
|
|
107
|
+
return this._allowedIps.has(ip) || this._allowedIps.has(normalized);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Add an IP to the allowlist at runtime (from dashboard).
|
|
112
|
+
*/
|
|
113
|
+
addAllowedIp(ip) {
|
|
114
|
+
this._allowedIps.add(ip.trim());
|
|
115
|
+
this._allowedIps.add("::ffff:" + ip.trim());
|
|
97
116
|
}
|
|
98
117
|
|
|
99
118
|
_extractKey(req) {
|