wolverine-ai 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/brain/brain.js +7 -3
- package/src/index.js +3 -1
- package/src/skills/sql.js +179 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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": {
|
package/src/brain/brain.js
CHANGED
|
@@ -96,7 +96,7 @@ const SEED_DOCS = [
|
|
|
96
96
|
metadata: { topic: "skill-sql-patterns" },
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
|
-
text: "Database best practices: SafeDB uses split connections — separate read connection (concurrent, never waits) and write connection (single writer, FIFO queue). Write queue drains synchronously in one microtask, zero delays. WAL mode means readers never block writers. Each write is microseconds. db.transaction(fn) queues as single atomic unit. No busy_timeout, no blocking, no IPC. Reads: db.get(), db.all() are instant. Writes: db.run(), db.exec() go through queue.",
|
|
99
|
+
text: "Database best practices: SafeDB uses split connections — separate read connection (concurrent, never waits) and write connection (single writer, FIFO queue). Write queue drains synchronously in one microtask, zero delays. WAL mode means readers never block writers. Each write is microseconds. db.transaction(fn) queues as single atomic unit. No busy_timeout, no blocking, no IPC. Reads: db.get(), db.all() are instant. Writes: db.run(), db.exec() go through queue. Idempotent writes: db.idempotent(key, fn, ttlSeconds) executes fn only once per key — prevents double-charge/double-insert when retries or cluster workers duplicate a request. Idempotency keys stored in _idempotency table (auto-created on connect), shared across all workers via WAL mode.",
|
|
100
100
|
metadata: { topic: "skill-sql-best-practices" },
|
|
101
101
|
},
|
|
102
102
|
{
|
|
@@ -120,7 +120,7 @@ const SEED_DOCS = [
|
|
|
120
120
|
metadata: { topic: "process-manager" },
|
|
121
121
|
},
|
|
122
122
|
{
|
|
123
|
-
text: "
|
|
123
|
+
text: "Cluster mode: server handles its own clustering (not wolverine-level). WOLVERINE_CLUSTER=true enables it. Server forks N workers (WOLVERINE_RECOMMENDED_WORKERS set by system detection). Workers share port 3000 via reusePort. Wolverine kills entire process tree on restart (_killProcessTree: taskkill /T on Windows, kill -pgid + pgrep -P on Linux). Idempotency protection prevents double-fire: idempotencyGuard() middleware deduplicates write requests across workers using shared SQLite _idempotency table. Client sends X-Idempotency-Key header, or auto-generated from method+path+body hash. All workers see the same table via WAL mode. SafeDB.idempotent(key, fn) for database-level dedup.",
|
|
124
124
|
metadata: { topic: "clustering" },
|
|
125
125
|
},
|
|
126
126
|
{
|
|
@@ -220,9 +220,13 @@ const SEED_DOCS = [
|
|
|
220
220
|
metadata: { topic: "agent-tools-v2" },
|
|
221
221
|
},
|
|
222
222
|
{
|
|
223
|
-
text: "Server problem categories the agent can fix: CODE BUGS (SyntaxError, TypeError, ReferenceError → edit_file), DEPENDENCIES (Cannot find module → npm install, corrupted node_modules → rm + reinstall), DATABASE (invalid entries → run_db_fix UPDATE, missing table → CREATE TABLE, schema mismatch → ALTER TABLE, constraint violation → fix data or schema), CONFIG (invalid JSON → edit_file, missing env vars → write .env, wrong port → edit config), FILESYSTEM (misplaced files → move_file, missing directories → bash_exec mkdir, wrong permissions → chmod), NETWORK (port conflict → check_port + kill, service down → restart, connection refused → check config), STATE (corrupted cache → delete + restart, stale locks → remove lock file, git conflicts → resolve markers). The agent investigates before fixing — reads files, checks directories, inspects databases, never guesses.",
|
|
223
|
+
text: "Server problem categories the agent can fix: CODE BUGS (SyntaxError, TypeError, ReferenceError → edit_file), DEPENDENCIES (Cannot find module → npm install, corrupted node_modules → rm + reinstall), DATABASE (invalid entries → run_db_fix UPDATE, missing table → CREATE TABLE, schema mismatch → ALTER TABLE, constraint violation → fix data or schema), CONFIG (invalid JSON → edit_file, missing env vars → write .env, wrong port → edit config), FILESYSTEM (misplaced files → move_file, missing directories → bash_exec mkdir, wrong permissions → chmod), NETWORK (port conflict → check_port + kill, service down → restart, connection refused → check config), STATE (corrupted cache → delete + restart, stale locks → remove lock file, git conflicts → resolve markers), IDEMPOTENCY (double-fire → add idempotencyGuard middleware, missing idempotency key → add X-Idempotency-Key header support, duplicate DB entries → add UNIQUE constraint or use db.idempotent()). The agent investigates before fixing — reads files, checks directories, inspects databases, never guesses.",
|
|
224
224
|
metadata: { topic: "server-problems" },
|
|
225
225
|
},
|
|
226
|
+
{
|
|
227
|
+
text: "Idempotency protection: two layers prevent double-fire in cluster mode. Layer 1: idempotencyGuard() Fastify middleware — intercepts POST/PUT/PATCH/DELETE, checks X-Idempotency-Key header (or auto-generates key from method+path+body hash), queries _idempotency table. If key exists and not expired → return cached response with X-Idempotency-Cached:true header, skip handler. If new → pass through, idempotencyAfterHook() stores response. Layer 2: SafeDB.idempotent(key, fn) — database-level dedup. Wraps fn in transaction, checks key, executes only if new. Returns {executed:true/false, result, cached}. Keys expire after TTL (default 24h). All workers share the SQLite _idempotency table via WAL mode — globally consistent. Auto-pruned on connect and via db.pruneIdempotency().",
|
|
228
|
+
metadata: { topic: "idempotency" },
|
|
229
|
+
},
|
|
226
230
|
{
|
|
227
231
|
text: "Heal pipeline no longer requires a file path. When no file is identified from the error (database errors, config problems, port conflicts), the pipeline skips fast path and goes straight to the agent, which uses investigation tools (glob_files, grep_code, list_dir, inspect_db, check_env, check_port) to find the root cause. Agent verification for no-file errors: if agent made changes or ran commands, trust the agent's assessment. For file-based errors, verification uses syntax check + boot probe as before.",
|
|
228
232
|
metadata: { topic: "fileless-heal" },
|
package/src/index.js
CHANGED
|
@@ -33,7 +33,7 @@ const { scanProject } = require("./brain/function-map");
|
|
|
33
33
|
const { detect: detectSystem } = require("./core/system-info");
|
|
34
34
|
const { ClusterManager } = require("./core/cluster-manager");
|
|
35
35
|
const { loadConfig, getConfig } = require("./core/config");
|
|
36
|
-
const { sqlGuard, SafeDB, scanForInjection } = require("./skills/sql");
|
|
36
|
+
const { sqlGuard, SafeDB, scanForInjection, idempotencyGuard, idempotencyAfterHook } = require("./skills/sql");
|
|
37
37
|
|
|
38
38
|
module.exports = {
|
|
39
39
|
// Core
|
|
@@ -93,4 +93,6 @@ module.exports = {
|
|
|
93
93
|
sqlGuard,
|
|
94
94
|
SafeDB,
|
|
95
95
|
scanForInjection,
|
|
96
|
+
idempotencyGuard,
|
|
97
|
+
idempotencyAfterHook,
|
|
96
98
|
};
|
package/src/skills/sql.js
CHANGED
|
@@ -201,6 +201,18 @@ class SafeDB {
|
|
|
201
201
|
this._writer.pragma("foreign_keys = ON");
|
|
202
202
|
this._writer.pragma("synchronous = NORMAL");
|
|
203
203
|
|
|
204
|
+
// Idempotency table — prevents double-execution of writes in cluster mode
|
|
205
|
+
this._writer.exec(`
|
|
206
|
+
CREATE TABLE IF NOT EXISTS _idempotency (
|
|
207
|
+
key TEXT PRIMARY KEY,
|
|
208
|
+
result TEXT,
|
|
209
|
+
created_at INTEGER DEFAULT (strftime('%s','now')),
|
|
210
|
+
expires_at INTEGER
|
|
211
|
+
)
|
|
212
|
+
`);
|
|
213
|
+
// Clean expired keys on connect
|
|
214
|
+
this._writer.exec(`DELETE FROM _idempotency WHERE expires_at < strftime('%s','now')`);
|
|
215
|
+
|
|
204
216
|
} catch (err) {
|
|
205
217
|
if (err.code === "MODULE_NOT_FOUND") {
|
|
206
218
|
throw new Error("Install better-sqlite3: npm install better-sqlite3");
|
|
@@ -216,6 +228,49 @@ class SafeDB {
|
|
|
216
228
|
process.on("exit", () => this.close());
|
|
217
229
|
}
|
|
218
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Idempotent write — execute fn only if this key hasn't been seen before.
|
|
233
|
+
* In cluster mode, prevents the same request from double-firing across workers.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} key — unique idempotency key (e.g. from X-Idempotency-Key header)
|
|
236
|
+
* @param {Function} fn — function that performs the write, receives writerProxy
|
|
237
|
+
* @param {number} ttlSeconds — how long to remember this key (default: 86400 = 24h)
|
|
238
|
+
* @returns {{ executed: boolean, result: any }} — executed=false if key was already seen
|
|
239
|
+
*/
|
|
240
|
+
idempotent(key, fn, ttlSeconds = 86400) {
|
|
241
|
+
this._assertOpen();
|
|
242
|
+
return this._enqueueWrite(() => {
|
|
243
|
+
// Check if key already executed
|
|
244
|
+
const existing = this._writer.prepare("SELECT result FROM _idempotency WHERE key = ? AND expires_at > strftime('%s','now')").get(key);
|
|
245
|
+
if (existing) {
|
|
246
|
+
return { executed: false, result: JSON.parse(existing.result || "null"), cached: true };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Execute the write
|
|
250
|
+
const txn = this._writer.transaction(() => {
|
|
251
|
+
const result = fn(this._writerProxy());
|
|
252
|
+
// Store the key so duplicates are rejected
|
|
253
|
+
this._writer.prepare(
|
|
254
|
+
"INSERT OR REPLACE INTO _idempotency (key, result, expires_at) VALUES (?, ?, strftime('%s','now') + ?)"
|
|
255
|
+
).run(key, JSON.stringify(result ?? null), ttlSeconds);
|
|
256
|
+
return result;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const result = txn();
|
|
260
|
+
return { executed: true, result, cached: false };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Clean up expired idempotency keys. Call periodically (e.g., every hour).
|
|
266
|
+
*/
|
|
267
|
+
pruneIdempotency() {
|
|
268
|
+
this._assertOpen();
|
|
269
|
+
return this._enqueueWrite(() => {
|
|
270
|
+
return this._writer.prepare("DELETE FROM _idempotency WHERE expires_at < strftime('%s','now')").run();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
219
274
|
/**
|
|
220
275
|
* Write query (INSERT, UPDATE, DELETE, CREATE).
|
|
221
276
|
* Queued and executed in order. Returns a promise that resolves with the result.
|
|
@@ -327,27 +382,137 @@ class SafeDB {
|
|
|
327
382
|
}
|
|
328
383
|
}
|
|
329
384
|
|
|
385
|
+
// ── Idempotency Middleware ──────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Request idempotency middleware — prevents double-fire in cluster mode.
|
|
389
|
+
*
|
|
390
|
+
* How it works:
|
|
391
|
+
* 1. Client sends write request (POST/PUT/PATCH/DELETE) with X-Idempotency-Key header
|
|
392
|
+
* 2. Middleware checks if this key was already processed
|
|
393
|
+
* 3. If yes: return cached response (no re-execution)
|
|
394
|
+
* 4. If no: execute handler, cache response, return result
|
|
395
|
+
*
|
|
396
|
+
* Without the header, mutating requests get an auto-generated key based on
|
|
397
|
+
* method + path + body hash. This means identical retries are deduplicated
|
|
398
|
+
* even without client cooperation.
|
|
399
|
+
*
|
|
400
|
+
* In cluster mode (reusePort), a retry can land on a different worker.
|
|
401
|
+
* Since all workers share the same SQLite database (WAL mode), the
|
|
402
|
+
* idempotency table is visible to all workers instantly.
|
|
403
|
+
*
|
|
404
|
+
* Safe methods (GET, HEAD, OPTIONS) are always passed through — they're
|
|
405
|
+
* inherently idempotent.
|
|
406
|
+
*
|
|
407
|
+
* @param {object} options
|
|
408
|
+
* @param {SafeDB} options.db — SafeDB instance (must be connected)
|
|
409
|
+
* @param {number} options.ttlSeconds — how long to cache responses (default: 86400)
|
|
410
|
+
* @param {object} options.logger — wolverine EventLogger (optional)
|
|
411
|
+
*/
|
|
412
|
+
function idempotencyGuard(options = {}) {
|
|
413
|
+
const db = options.db;
|
|
414
|
+
const ttlSeconds = options.ttlSeconds || 86400;
|
|
415
|
+
const logger = options.logger || null;
|
|
416
|
+
const crypto = require("crypto");
|
|
417
|
+
|
|
418
|
+
return async (req, res, next) => {
|
|
419
|
+
// Safe methods are inherently idempotent — pass through
|
|
420
|
+
const method = (req.method || "GET").toUpperCase();
|
|
421
|
+
if (["GET", "HEAD", "OPTIONS"].includes(method)) return next();
|
|
422
|
+
|
|
423
|
+
// Get or generate idempotency key
|
|
424
|
+
let key = req.headers["x-idempotency-key"] || req.headers["idempotency-key"];
|
|
425
|
+
if (!key) {
|
|
426
|
+
// Auto-generate from method + path + body hash
|
|
427
|
+
const bodyStr = typeof req.body === "string" ? req.body : JSON.stringify(req.body || "");
|
|
428
|
+
key = crypto.createHash("sha256").update(`${method}:${req.url}:${bodyStr}`).digest("hex");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!db || !db._writer) return next(); // No DB — can't check, pass through
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
// Check idempotency table directly (read from writer for consistency)
|
|
435
|
+
const existing = db._writer.prepare(
|
|
436
|
+
"SELECT result FROM _idempotency WHERE key = ? AND expires_at > strftime('%s','now')"
|
|
437
|
+
).get(key);
|
|
438
|
+
|
|
439
|
+
if (existing) {
|
|
440
|
+
// Already processed — return cached response
|
|
441
|
+
const cached = JSON.parse(existing.result || "null");
|
|
442
|
+
if (logger) logger.debug("idempotency.hit", `Duplicate request blocked: ${method} ${req.url}`, { key: key.slice(0, 16) });
|
|
443
|
+
|
|
444
|
+
const status = cached?.statusCode || 200;
|
|
445
|
+
const body = cached?.body || cached;
|
|
446
|
+
if (typeof res.code === "function") {
|
|
447
|
+
// Fastify
|
|
448
|
+
res.code(status).header("X-Idempotency-Cached", "true").send(body);
|
|
449
|
+
} else {
|
|
450
|
+
// Express
|
|
451
|
+
res.status(status).set("X-Idempotency-Cached", "true").json(body);
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Not seen — attach key to request for the route handler to use
|
|
457
|
+
req._idempotencyKey = key;
|
|
458
|
+
req._idempotencyTtl = ttlSeconds;
|
|
459
|
+
} catch {
|
|
460
|
+
// DB error — don't block the request, just pass through
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
next();
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* After-response hook — stores the response for future idempotency checks.
|
|
469
|
+
* For Fastify, add as onSend hook. For Express, monkey-patch res.json.
|
|
470
|
+
*
|
|
471
|
+
* @param {SafeDB} db — connected SafeDB instance
|
|
472
|
+
*/
|
|
473
|
+
function idempotencyAfterHook(db) {
|
|
474
|
+
return (req, reply, payload, done) => {
|
|
475
|
+
if (req._idempotencyKey && db && db._writer) {
|
|
476
|
+
try {
|
|
477
|
+
const statusCode = reply.statusCode || 200;
|
|
478
|
+
const result = JSON.stringify({ statusCode, body: typeof payload === "string" ? JSON.parse(payload) : payload });
|
|
479
|
+
db._writer.prepare(
|
|
480
|
+
"INSERT OR IGNORE INTO _idempotency (key, result, expires_at) VALUES (?, ?, strftime('%s','now') + ?)"
|
|
481
|
+
).run(req._idempotencyKey, result, req._idempotencyTtl || 86400);
|
|
482
|
+
} catch { /* non-fatal */ }
|
|
483
|
+
}
|
|
484
|
+
done();
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
330
488
|
// ── Skill Metadata (for SkillRegistry discovery) ──
|
|
331
489
|
|
|
332
490
|
const SKILL_NAME = "sql";
|
|
333
|
-
const SKILL_DESCRIPTION = "SQL database interface with injection prevention. Provides sqlGuard() middleware to block SQL injection
|
|
334
|
-
const SKILL_KEYWORDS = ["sql", "database", "db", "query", "injection", "sqlite", "postgres", "mysql", "select", "insert", "update", "delete", "table", "schema", "migration", "parameterized"];
|
|
335
|
-
const SKILL_USAGE = `// Protect
|
|
336
|
-
const { sqlGuard } = require("../src/skills/sql");
|
|
337
|
-
app.use(sqlGuard({ logger: wolverineLogger }));
|
|
338
|
-
|
|
339
|
-
// Cluster-safe database (each worker gets its own connection)
|
|
340
|
-
const { SafeDB } = require("../src/skills/sql");
|
|
491
|
+
const SKILL_DESCRIPTION = "SQL database interface with injection prevention + idempotency. Provides sqlGuard() middleware to block SQL injection, idempotencyGuard() middleware to prevent double-fire in cluster mode, and SafeDB class for parameterized-only database queries with built-in idempotency key support.";
|
|
492
|
+
const SKILL_KEYWORDS = ["sql", "database", "db", "query", "injection", "sqlite", "postgres", "mysql", "select", "insert", "update", "delete", "table", "schema", "migration", "parameterized", "idempotent", "idempotency", "duplicate", "double", "cluster", "transaction"];
|
|
493
|
+
const SKILL_USAGE = `// Protect routes from SQL injection + double-fire
|
|
494
|
+
const { sqlGuard, idempotencyGuard, idempotencyAfterHook, SafeDB } = require("../src/skills/sql");
|
|
341
495
|
const db = new SafeDB({ type: "sqlite", path: "./server/data.db" });
|
|
342
|
-
await db.connect();
|
|
496
|
+
await db.connect();
|
|
497
|
+
|
|
498
|
+
// Middleware: injection prevention + idempotency (cluster-safe)
|
|
499
|
+
fastify.addHook("preHandler", sqlGuard({ logger }));
|
|
500
|
+
fastify.addHook("preHandler", idempotencyGuard({ db, logger }));
|
|
501
|
+
fastify.addHook("onSend", idempotencyAfterHook(db));
|
|
343
502
|
|
|
344
|
-
// Reads (concurrent across workers)
|
|
503
|
+
// Reads (concurrent across workers — never waits)
|
|
345
504
|
const users = db.all("SELECT * FROM users WHERE role = ?", ["admin"]);
|
|
346
505
|
|
|
347
|
-
// Writes (serialized — no corruption)
|
|
506
|
+
// Writes (serialized FIFO queue — no corruption)
|
|
348
507
|
db.run("INSERT INTO users (name, role) VALUES (?, ?)", ["Alice", "admin"]);
|
|
349
508
|
|
|
350
|
-
//
|
|
509
|
+
// Idempotent write — prevents double-charge/double-insert in cluster mode
|
|
510
|
+
const result = await db.idempotent("order-abc-123", (tx) => {
|
|
511
|
+
tx.run("INSERT INTO orders (id, total) VALUES (?, ?)", ["abc-123", 99.99]);
|
|
512
|
+
return { orderId: "abc-123" };
|
|
513
|
+
}); // result.executed=true first time, false on retry
|
|
514
|
+
|
|
515
|
+
// Atomic transaction (all-or-nothing)
|
|
351
516
|
db.transaction((tx) => {
|
|
352
517
|
tx.run("INSERT INTO orders (user_id, total) VALUES (?, ?)", [1, 99.99]);
|
|
353
518
|
tx.run("UPDATE users SET order_count = order_count + 1 WHERE id = ?", [1]);
|
|
@@ -364,6 +529,8 @@ module.exports = {
|
|
|
364
529
|
|
|
365
530
|
// Middleware
|
|
366
531
|
sqlGuard,
|
|
532
|
+
idempotencyGuard,
|
|
533
|
+
idempotencyAfterHook,
|
|
367
534
|
scanForInjection,
|
|
368
535
|
deepScan,
|
|
369
536
|
|