nodebench-mcp 2.31.1 → 2.32.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.
Files changed (94) hide show
  1. package/README.md +14 -6
  2. package/dist/engine/server.js +14 -4
  3. package/dist/engine/server.js.map +1 -1
  4. package/dist/index.js +1946 -670
  5. package/dist/index.js.map +1 -1
  6. package/dist/security/SecurityError.d.ts +18 -0
  7. package/dist/security/SecurityError.js +22 -0
  8. package/dist/security/SecurityError.js.map +1 -0
  9. package/dist/security/__tests__/security.test.d.ts +8 -0
  10. package/dist/security/__tests__/security.test.js +295 -0
  11. package/dist/security/__tests__/security.test.js.map +1 -0
  12. package/dist/security/auditLog.d.ts +36 -0
  13. package/dist/security/auditLog.js +178 -0
  14. package/dist/security/auditLog.js.map +1 -0
  15. package/dist/security/commandSandbox.d.ts +33 -0
  16. package/dist/security/commandSandbox.js +159 -0
  17. package/dist/security/commandSandbox.js.map +1 -0
  18. package/dist/security/config.d.ts +23 -0
  19. package/dist/security/config.js +43 -0
  20. package/dist/security/config.js.map +1 -0
  21. package/dist/security/credentialRedactor.d.ts +22 -0
  22. package/dist/security/credentialRedactor.js +118 -0
  23. package/dist/security/credentialRedactor.js.map +1 -0
  24. package/dist/security/index.d.ts +20 -0
  25. package/dist/security/index.js +21 -0
  26. package/dist/security/index.js.map +1 -0
  27. package/dist/security/pathSandbox.d.ts +23 -0
  28. package/dist/security/pathSandbox.js +160 -0
  29. package/dist/security/pathSandbox.js.map +1 -0
  30. package/dist/security/urlValidator.d.ts +23 -0
  31. package/dist/security/urlValidator.js +125 -0
  32. package/dist/security/urlValidator.js.map +1 -0
  33. package/dist/tools/agentBootstrapTools.js +22 -29
  34. package/dist/tools/agentBootstrapTools.js.map +1 -1
  35. package/dist/tools/contextSandboxTools.js +7 -9
  36. package/dist/tools/contextSandboxTools.js.map +1 -1
  37. package/dist/tools/deepSimTools.d.ts +2 -0
  38. package/dist/tools/deepSimTools.js +404 -0
  39. package/dist/tools/deepSimTools.js.map +1 -0
  40. package/dist/tools/dimensionTools.d.ts +2 -0
  41. package/dist/tools/dimensionTools.js +246 -0
  42. package/dist/tools/dimensionTools.js.map +1 -0
  43. package/dist/tools/executionTraceTools.d.ts +2 -0
  44. package/dist/tools/executionTraceTools.js +446 -0
  45. package/dist/tools/executionTraceTools.js.map +1 -0
  46. package/dist/tools/founderTools.d.ts +13 -0
  47. package/dist/tools/founderTools.js +595 -0
  48. package/dist/tools/founderTools.js.map +1 -0
  49. package/dist/tools/gitWorkflowTools.js +14 -10
  50. package/dist/tools/gitWorkflowTools.js.map +1 -1
  51. package/dist/tools/githubTools.js +19 -2
  52. package/dist/tools/githubTools.js.map +1 -1
  53. package/dist/tools/index.d.ts +87 -0
  54. package/dist/tools/index.js +102 -0
  55. package/dist/tools/index.js.map +1 -0
  56. package/dist/tools/localFileTools.js +24 -12
  57. package/dist/tools/localFileTools.js.map +1 -1
  58. package/dist/tools/memoryDecay.d.ts +70 -0
  59. package/dist/tools/memoryDecay.js +247 -0
  60. package/dist/tools/memoryDecay.js.map +1 -0
  61. package/dist/tools/missionHarnessTools.d.ts +32 -0
  62. package/dist/tools/missionHarnessTools.js +972 -0
  63. package/dist/tools/missionHarnessTools.js.map +1 -0
  64. package/dist/tools/observabilityTools.d.ts +15 -0
  65. package/dist/tools/observabilityTools.js +787 -0
  66. package/dist/tools/observabilityTools.js.map +1 -0
  67. package/dist/tools/openclawTools.js +151 -36
  68. package/dist/tools/openclawTools.js.map +1 -1
  69. package/dist/tools/progressiveDiscoveryTools.js +5 -4
  70. package/dist/tools/progressiveDiscoveryTools.js.map +1 -1
  71. package/dist/tools/qualityGateTools.js +118 -2
  72. package/dist/tools/qualityGateTools.js.map +1 -1
  73. package/dist/tools/rssTools.js +3 -0
  74. package/dist/tools/rssTools.js.map +1 -1
  75. package/dist/tools/scraplingTools.js +15 -0
  76. package/dist/tools/scraplingTools.js.map +1 -1
  77. package/dist/tools/seoTools.js +66 -1
  78. package/dist/tools/seoTools.js.map +1 -1
  79. package/dist/tools/sessionMemoryTools.js +50 -11
  80. package/dist/tools/sessionMemoryTools.js.map +1 -1
  81. package/dist/tools/temporalIntelligenceTools.d.ts +12 -0
  82. package/dist/tools/temporalIntelligenceTools.js +1068 -0
  83. package/dist/tools/temporalIntelligenceTools.js.map +1 -0
  84. package/dist/tools/toolRegistry.d.ts +19 -0
  85. package/dist/tools/toolRegistry.js +857 -31
  86. package/dist/tools/toolRegistry.js.map +1 -1
  87. package/dist/tools/webTools.js +14 -1
  88. package/dist/tools/webTools.js.map +1 -1
  89. package/dist/tools/webmcpTools.js +13 -2
  90. package/dist/tools/webmcpTools.js.map +1 -1
  91. package/dist/toolsetRegistry.js +13 -0
  92. package/dist/toolsetRegistry.js.map +1 -1
  93. package/dist/types.d.ts +10 -0
  94. package/package.json +124 -124
@@ -0,0 +1,787 @@
1
+ /**
2
+ * Observability Tools — Continuous system health, drift detection, and autonomous maintenance.
3
+ *
4
+ * Provides:
5
+ * 1. Real-time system pulse (health snapshot)
6
+ * 2. Drift detection (expected vs actual state)
7
+ * 3. Self-healing for common issues
8
+ * 4. Background watchdog with configurable intervals
9
+ * 5. Uptime statistics and error trend analysis
10
+ */
11
+ import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { createRequire } from "node:module";
15
+ const _require = createRequire(import.meta.url);
16
+ function _isInstalled(pkg) {
17
+ try {
18
+ _require.resolve(pkg);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ // Singleton watchdog state
26
+ let watchdogConfig = {
27
+ enabled: true,
28
+ intervalMs: 300_000, // 5 minutes
29
+ maxLogEntries: 100,
30
+ thresholds: {
31
+ dbSizeMb: 500,
32
+ errorRatePercent: 20,
33
+ staleEmbeddingCacheHours: 168, // 7 days
34
+ orphanedCycleHours: 48,
35
+ analyticsRetentionDays: 90,
36
+ },
37
+ };
38
+ const watchdogLog = [];
39
+ let watchdogTimer = null;
40
+ const serverStartTime = Date.now();
41
+ // Lazy DB accessor — set by init function
42
+ let _getDb = null;
43
+ export function initObservability(getDb) {
44
+ _getDb = getDb;
45
+ }
46
+ // ── Helpers ─────────────────────────────────────────────────────────────
47
+ function nodebenchDir() {
48
+ return join(homedir(), ".nodebench");
49
+ }
50
+ function fileInfo(path) {
51
+ if (!existsSync(path))
52
+ return { exists: false, sizeKb: 0, ageHours: 0 };
53
+ const stat = statSync(path);
54
+ return {
55
+ exists: true,
56
+ sizeKb: Math.round(stat.size / 1024),
57
+ ageHours: Math.round((Date.now() - stat.mtimeMs) / 3_600_000),
58
+ };
59
+ }
60
+ async function checkDashboard(port) {
61
+ try {
62
+ const resp = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500) });
63
+ return resp.ok;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ function getRecentErrors(db, windowMinutes) {
70
+ try {
71
+ const cutoff = new Date(Date.now() - windowMinutes * 60_000).toISOString();
72
+ const row = db.prepare(`SELECT COUNT(*) as cnt FROM tool_call_log WHERE result_status = 'error' AND created_at > ?`).get(cutoff);
73
+ return row?.cnt ?? 0;
74
+ }
75
+ catch {
76
+ return 0;
77
+ }
78
+ }
79
+ function getRecentCallCount(db, windowMinutes) {
80
+ try {
81
+ const cutoff = new Date(Date.now() - windowMinutes * 60_000).toISOString();
82
+ const row = db.prepare(`SELECT COUNT(*) as cnt FROM tool_call_log WHERE created_at > ?`).get(cutoff);
83
+ return row?.cnt ?? 0;
84
+ }
85
+ catch {
86
+ return 0;
87
+ }
88
+ }
89
+ function getOrphanedCycles(db, hoursOld) {
90
+ try {
91
+ const cutoff = new Date(Date.now() - hoursOld * 3_600_000).toISOString();
92
+ return db.prepare(`SELECT id, title, status, created_at FROM verification_cycles WHERE status IN ('active', 'in_progress') AND created_at < ?`).all(cutoff);
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ }
98
+ function getStaleEvalRuns(db) {
99
+ try {
100
+ return db.prepare(`SELECT id, name, created_at FROM eval_runs WHERE status IN ('running', 'pending') AND created_at < datetime('now', '-24 hours')`).all();
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ }
106
+ // ── Core: System Pulse ──────────────────────────────────────────────────
107
+ async function computeSystemPulse(db, toolCount) {
108
+ const dir = nodebenchDir();
109
+ const dbInfo = fileInfo(join(dir, "nodebench.db"));
110
+ const analyticsInfo = fileInfo(join(dir, "analytics.db"));
111
+ const cacheInfo = fileInfo(join(dir, "embedding_cache.json"));
112
+ const [mainUp, briefUp, engineUp] = await Promise.all([
113
+ checkDashboard(6274),
114
+ checkDashboard(6275),
115
+ checkDashboard(6276),
116
+ ]);
117
+ const errors5min = getRecentErrors(db, 5);
118
+ const errors1hr = getRecentErrors(db, 60);
119
+ const errors24hr = getRecentErrors(db, 1440);
120
+ // Health score: 100 base, deduct for issues
121
+ let healthScore = 100;
122
+ if (!dbInfo.exists)
123
+ healthScore -= 30;
124
+ if (dbInfo.sizeKb > watchdogConfig.thresholds.dbSizeMb * 1024)
125
+ healthScore -= 10;
126
+ if (!mainUp)
127
+ healthScore -= 10;
128
+ const calls1hr = getRecentCallCount(db, 60);
129
+ const errorRate = calls1hr > 0 ? (errors1hr / calls1hr) * 100 : 0;
130
+ if (errorRate > watchdogConfig.thresholds.errorRatePercent)
131
+ healthScore -= 20;
132
+ if (errorRate > 50)
133
+ healthScore -= 20;
134
+ if (cacheInfo.ageHours > watchdogConfig.thresholds.staleEmbeddingCacheHours)
135
+ healthScore -= 5;
136
+ return {
137
+ timestamp: new Date().toISOString(),
138
+ uptime: { startedAt: new Date(serverStartTime).toISOString(), durationMs: Date.now() - serverStartTime },
139
+ database: { exists: dbInfo.exists, sizeKb: dbInfo.sizeKb, path: join(dir, "nodebench.db") },
140
+ analytics: { exists: analyticsInfo.exists, sizeKb: analyticsInfo.sizeKb },
141
+ embeddingCache: { exists: cacheInfo.exists, sizeKb: cacheInfo.sizeKb, ageHours: cacheInfo.ageHours },
142
+ toolCount,
143
+ dashboards: { main: mainUp, brief: briefUp, engine: engineUp },
144
+ recentErrors: { last5min: errors5min, last1hr: errors1hr, last24hr: errors24hr },
145
+ healthScore: Math.max(0, healthScore),
146
+ };
147
+ }
148
+ // ── Core: Drift Detection ───────────────────────────────────────────────
149
+ function runDriftChecks(db) {
150
+ const checks = [];
151
+ const dir = nodebenchDir();
152
+ const { thresholds } = watchdogConfig;
153
+ // 1. DB size drift
154
+ const dbInfo = fileInfo(join(dir, "nodebench.db"));
155
+ if (dbInfo.exists && dbInfo.sizeKb > thresholds.dbSizeMb * 1024) {
156
+ checks.push({
157
+ id: "db_size",
158
+ name: "Database size exceeds threshold",
159
+ status: dbInfo.sizeKb > thresholds.dbSizeMb * 2 * 1024 ? "critical" : "warning",
160
+ detail: `${(dbInfo.sizeKb / 1024).toFixed(1)} MB (threshold: ${thresholds.dbSizeMb} MB)`,
161
+ healable: true,
162
+ });
163
+ }
164
+ else {
165
+ checks.push({ id: "db_size", name: "Database size", status: "ok", detail: `${(dbInfo.sizeKb / 1024).toFixed(1)} MB`, healable: false });
166
+ }
167
+ // 2. Orphaned verification cycles
168
+ const orphaned = getOrphanedCycles(db, thresholds.orphanedCycleHours);
169
+ if (orphaned.length > 0) {
170
+ checks.push({
171
+ id: "orphaned_cycles",
172
+ name: "Orphaned verification cycles",
173
+ status: orphaned.length > 5 ? "critical" : "warning",
174
+ detail: `${orphaned.length} cycles stuck in active/in_progress for >${thresholds.orphanedCycleHours}h`,
175
+ healable: true,
176
+ });
177
+ }
178
+ else {
179
+ checks.push({ id: "orphaned_cycles", name: "Verification cycles", status: "ok", detail: "No orphaned cycles", healable: false });
180
+ }
181
+ // 3. Stale eval runs
182
+ const staleRuns = getStaleEvalRuns(db);
183
+ if (staleRuns.length > 0) {
184
+ checks.push({
185
+ id: "stale_evals",
186
+ name: "Stale eval runs",
187
+ status: "warning",
188
+ detail: `${staleRuns.length} eval runs stuck in running/pending for >24h`,
189
+ healable: true,
190
+ });
191
+ }
192
+ else {
193
+ checks.push({ id: "stale_evals", name: "Eval runs", status: "ok", detail: "No stale runs", healable: false });
194
+ }
195
+ // 4. Embedding cache freshness
196
+ const cacheInfo = fileInfo(join(dir, "embedding_cache.json"));
197
+ if (cacheInfo.exists && cacheInfo.ageHours > thresholds.staleEmbeddingCacheHours) {
198
+ checks.push({
199
+ id: "embedding_cache_stale",
200
+ name: "Embedding cache stale",
201
+ status: "warning",
202
+ detail: `Last updated ${cacheInfo.ageHours}h ago (threshold: ${thresholds.staleEmbeddingCacheHours}h)`,
203
+ healable: false, // Requires re-init, not a simple fix
204
+ });
205
+ }
206
+ else if (cacheInfo.exists) {
207
+ checks.push({ id: "embedding_cache_stale", name: "Embedding cache", status: "ok", detail: `${cacheInfo.ageHours}h old, ${cacheInfo.sizeKb} KB`, healable: false });
208
+ }
209
+ // 5. Error rate spike
210
+ const calls1hr = getRecentCallCount(db, 60);
211
+ const errors1hr = getRecentErrors(db, 60);
212
+ const errorRate = calls1hr > 0 ? (errors1hr / calls1hr) * 100 : 0;
213
+ if (errorRate > thresholds.errorRatePercent && calls1hr > 5) {
214
+ checks.push({
215
+ id: "error_rate",
216
+ name: "Error rate spike",
217
+ status: errorRate > 50 ? "critical" : "warning",
218
+ detail: `${errorRate.toFixed(1)}% errors in last hour (${errors1hr}/${calls1hr} calls, threshold: ${thresholds.errorRatePercent}%)`,
219
+ healable: false,
220
+ });
221
+ }
222
+ else {
223
+ checks.push({ id: "error_rate", name: "Error rate", status: "ok", detail: `${errorRate.toFixed(1)}% (${errors1hr}/${calls1hr})`, healable: false });
224
+ }
225
+ // 6. Analytics data retention
226
+ const analyticsInfo = fileInfo(join(dir, "analytics.db"));
227
+ if (analyticsInfo.exists && analyticsInfo.sizeKb > 50_000) {
228
+ checks.push({
229
+ id: "analytics_bloat",
230
+ name: "Analytics DB large",
231
+ status: "warning",
232
+ detail: `${(analyticsInfo.sizeKb / 1024).toFixed(1)} MB — may benefit from retention cleanup`,
233
+ healable: true,
234
+ });
235
+ }
236
+ else {
237
+ checks.push({ id: "analytics_bloat", name: "Analytics size", status: "ok", detail: `${(analyticsInfo.sizeKb / 1024).toFixed(1)} MB`, healable: false });
238
+ }
239
+ // 7. Notes directory growth
240
+ const notesDir = join(dir, "notes");
241
+ if (existsSync(notesDir)) {
242
+ try {
243
+ const noteFiles = readdirSync(notesDir).filter(f => f.endsWith(".md"));
244
+ if (noteFiles.length > 200) {
245
+ checks.push({
246
+ id: "notes_growth",
247
+ name: "Session notes accumulation",
248
+ status: "warning",
249
+ detail: `${noteFiles.length} note files — consider archiving old notes`,
250
+ healable: false,
251
+ });
252
+ }
253
+ else {
254
+ checks.push({ id: "notes_growth", name: "Session notes", status: "ok", detail: `${noteFiles.length} files`, healable: false });
255
+ }
256
+ }
257
+ catch {
258
+ checks.push({ id: "notes_growth", name: "Session notes", status: "ok", detail: "Directory not readable", healable: false });
259
+ }
260
+ }
261
+ return checks;
262
+ }
263
+ // ── Core: Self-Heal ─────────────────────────────────────────────────────
264
+ function runSelfHeal(db, targets) {
265
+ const healed = [];
266
+ const shouldHeal = (id) => !targets || targets.length === 0 || targets.includes(id);
267
+ // Heal orphaned verification cycles
268
+ if (shouldHeal("orphaned_cycles")) {
269
+ const orphaned = getOrphanedCycles(db, watchdogConfig.thresholds.orphanedCycleHours);
270
+ for (const cycle of orphaned) {
271
+ try {
272
+ db.prepare(`UPDATE verification_cycles SET status = 'abandoned', updated_at = datetime('now') WHERE id = ?`).run(cycle.id);
273
+ healed.push(`Abandoned orphaned cycle ${cycle.id} ("${cycle.title}")`);
274
+ }
275
+ catch { /* skip */ }
276
+ }
277
+ }
278
+ // Heal stale eval runs
279
+ if (shouldHeal("stale_evals")) {
280
+ const stale = getStaleEvalRuns(db);
281
+ for (const run of stale) {
282
+ try {
283
+ db.prepare(`UPDATE eval_runs SET status = 'failed', completed_at = datetime('now') WHERE id = ?`).run(run.id);
284
+ healed.push(`Marked stale eval run ${run.id} as failed`);
285
+ }
286
+ catch { /* skip */ }
287
+ }
288
+ }
289
+ // Heal DB bloat via VACUUM (if over 2x threshold)
290
+ if (shouldHeal("db_size")) {
291
+ const dbInfo = fileInfo(join(nodebenchDir(), "nodebench.db"));
292
+ if (dbInfo.sizeKb > watchdogConfig.thresholds.dbSizeMb * 2 * 1024) {
293
+ try {
294
+ // Delete old tool call logs (>90 days)
295
+ const cutoff = new Date(Date.now() - 90 * 24 * 3_600_000).toISOString();
296
+ const deleted = db.prepare(`DELETE FROM tool_call_log WHERE created_at < ?`).run(cutoff);
297
+ if (deleted.changes > 0) {
298
+ healed.push(`Pruned ${deleted.changes} tool_call_log entries older than 90 days`);
299
+ }
300
+ db.pragma("wal_checkpoint(TRUNCATE)");
301
+ healed.push("Ran WAL checkpoint (TRUNCATE)");
302
+ }
303
+ catch { /* skip */ }
304
+ }
305
+ }
306
+ // Heal analytics bloat
307
+ if (shouldHeal("analytics_bloat")) {
308
+ const analyticsInfo = fileInfo(join(nodebenchDir(), "analytics.db"));
309
+ if (analyticsInfo.sizeKb > 50_000) {
310
+ try {
311
+ // Import and use analytics cleanup
312
+ healed.push("Analytics cleanup recommended — run: nodebench-mcp --reset-stats");
313
+ }
314
+ catch { /* skip */ }
315
+ }
316
+ }
317
+ return healed;
318
+ }
319
+ // ── Core: Uptime Stats ──────────────────────────────────────────────────
320
+ function computeUptimeStats(db) {
321
+ const durationMs = Date.now() - serverStartTime;
322
+ const windows = [
323
+ { label: "5min", minutes: 5 },
324
+ { label: "1hr", minutes: 60 },
325
+ { label: "24hr", minutes: 1440 },
326
+ { label: "7d", minutes: 10080 },
327
+ ];
328
+ const stats = {
329
+ serverStart: new Date(serverStartTime).toISOString(),
330
+ uptimeMs: durationMs,
331
+ uptimeHuman: formatDuration(durationMs),
332
+ };
333
+ for (const { label, minutes } of windows) {
334
+ const calls = getRecentCallCount(db, minutes);
335
+ const errors = getRecentErrors(db, minutes);
336
+ const rate = calls > 0 ? ((calls - errors) / calls * 100).toFixed(1) : "N/A";
337
+ stats[label] = { calls, errors, successRate: `${rate}%`, callsPerMinute: (calls / minutes).toFixed(2) };
338
+ }
339
+ // Top 10 tools by call count in last 24h
340
+ try {
341
+ const cutoff = new Date(Date.now() - 24 * 3_600_000).toISOString();
342
+ stats.topTools24h = db.prepare(`SELECT tool_name, COUNT(*) as calls, SUM(CASE WHEN result_status = 'error' THEN 1 ELSE 0 END) as errors,
343
+ ROUND(AVG(duration_ms)) as avg_ms
344
+ FROM tool_call_log WHERE created_at > ? GROUP BY tool_name ORDER BY calls DESC LIMIT 10`).all(cutoff);
345
+ }
346
+ catch {
347
+ stats.topTools24h = [];
348
+ }
349
+ // Error trend: compare last hour vs previous hour
350
+ try {
351
+ const now = Date.now();
352
+ const errorsThisHour = getRecentErrors(db, 60);
353
+ const cutoffPrev = new Date(now - 120 * 60_000).toISOString();
354
+ const cutoffThisHour = new Date(now - 60 * 60_000).toISOString();
355
+ const row = db.prepare(`SELECT COUNT(*) as cnt FROM tool_call_log WHERE result_status = 'error' AND created_at > ? AND created_at <= ?`).get(cutoffPrev, cutoffThisHour);
356
+ const errorsPrevHour = row?.cnt ?? 0;
357
+ stats.errorTrend = {
358
+ thisHour: errorsThisHour,
359
+ prevHour: errorsPrevHour,
360
+ direction: errorsThisHour > errorsPrevHour ? "increasing" : errorsThisHour < errorsPrevHour ? "decreasing" : "stable",
361
+ };
362
+ }
363
+ catch {
364
+ stats.errorTrend = { thisHour: 0, prevHour: 0, direction: "stable" };
365
+ }
366
+ return stats;
367
+ }
368
+ function formatDuration(ms) {
369
+ const s = Math.floor(ms / 1000);
370
+ const m = Math.floor(s / 60);
371
+ const h = Math.floor(m / 60);
372
+ const d = Math.floor(h / 24);
373
+ if (d > 0)
374
+ return `${d}d ${h % 24}h ${m % 60}m`;
375
+ if (h > 0)
376
+ return `${h}h ${m % 60}m`;
377
+ if (m > 0)
378
+ return `${m}m ${s % 60}s`;
379
+ return `${s}s`;
380
+ }
381
+ // ── Watchdog Background Loop ────────────────────────────────────────────
382
+ function runWatchdogCycle(db) {
383
+ const checks = runDriftChecks(db);
384
+ const criticals = checks.filter(c => c.status === "critical");
385
+ const warnings = checks.filter(c => c.status === "warning");
386
+ // Auto-heal critical healable issues
387
+ let healed = [];
388
+ const healableTargets = criticals.filter(c => c.healable).map(c => c.id);
389
+ if (healableTargets.length > 0) {
390
+ healed = runSelfHeal(db, healableTargets);
391
+ }
392
+ const healthScore = Math.max(0, 100 - criticals.length * 20 - warnings.length * 5);
393
+ const entry = {
394
+ timestamp: new Date().toISOString(),
395
+ checks,
396
+ healthScore,
397
+ healed,
398
+ };
399
+ watchdogLog.push(entry);
400
+ if (watchdogLog.length > watchdogConfig.maxLogEntries) {
401
+ watchdogLog.splice(0, watchdogLog.length - watchdogConfig.maxLogEntries);
402
+ }
403
+ return entry;
404
+ }
405
+ export function startWatchdog(db) {
406
+ if (watchdogTimer)
407
+ return;
408
+ if (!watchdogConfig.enabled)
409
+ return;
410
+ // Run initial check
411
+ runWatchdogCycle(db);
412
+ watchdogTimer = setInterval(() => {
413
+ try {
414
+ runWatchdogCycle(db);
415
+ }
416
+ catch { /* never crash the server */ }
417
+ }, watchdogConfig.intervalMs);
418
+ // Unref so it doesn't prevent process exit
419
+ if (watchdogTimer && typeof watchdogTimer.unref === "function") {
420
+ watchdogTimer.unref();
421
+ }
422
+ }
423
+ export function stopWatchdog() {
424
+ if (watchdogTimer) {
425
+ clearInterval(watchdogTimer);
426
+ watchdogTimer = null;
427
+ }
428
+ }
429
+ // ── MCP Tool Definitions ────────────────────────────────────────────────
430
+ function getDb() {
431
+ if (!_getDb)
432
+ throw new Error("Observability not initialized — call initObservability(getDb) first");
433
+ return _getDb();
434
+ }
435
+ export const observabilityTools = [
436
+ {
437
+ name: "get_system_pulse",
438
+ description: "Get a real-time health snapshot of the NodeBench MCP system. Returns database status, " +
439
+ "dashboard availability, embedding cache freshness, recent error rates, and overall health score (0-100). " +
440
+ "Think of it like a vital signs monitor — one glance tells you if the system is healthy.",
441
+ inputSchema: {
442
+ type: "object",
443
+ properties: {
444
+ include_dashboards: {
445
+ type: "boolean",
446
+ description: "Check dashboard HTTP endpoints (adds ~1.5s latency). Default: true.",
447
+ },
448
+ },
449
+ required: [],
450
+ },
451
+ handler: async (args) => {
452
+ const db = getDb();
453
+ const toolCount = 0; // Will be overridden at registration
454
+ const pulse = await computeSystemPulse(db, args._toolCount ?? toolCount);
455
+ if (args.include_dashboards === false) {
456
+ pulse.dashboards = { main: false, brief: false, engine: false };
457
+ }
458
+ return {
459
+ pulse,
460
+ interpretation: pulse.healthScore >= 90 ? "System is healthy"
461
+ : pulse.healthScore >= 70 ? "System has minor issues — check warnings"
462
+ : pulse.healthScore >= 50 ? "System needs attention — multiple issues detected"
463
+ : "System is degraded — immediate action recommended",
464
+ _hint: pulse.healthScore < 70 ? "Run get_drift_report for details, then run_self_heal to fix healable issues." : undefined,
465
+ };
466
+ },
467
+ },
468
+ {
469
+ name: "get_drift_report",
470
+ description: "Detect configuration and state drift in the NodeBench system. Checks for orphaned verification " +
471
+ "cycles, stale eval runs, database bloat, error rate spikes, embedding cache staleness, and notes " +
472
+ "accumulation. Each issue is classified as ok/warning/critical with healable flag. " +
473
+ "Think of it like a home inspection — it finds the leaky faucets before they become floods.",
474
+ inputSchema: {
475
+ type: "object",
476
+ properties: {
477
+ include_history: {
478
+ type: "boolean",
479
+ description: "Include last 10 watchdog log entries for trend analysis. Default: false.",
480
+ },
481
+ },
482
+ required: [],
483
+ },
484
+ handler: async (args) => {
485
+ const db = getDb();
486
+ const checks = runDriftChecks(db);
487
+ const criticals = checks.filter(c => c.status === "critical");
488
+ const warnings = checks.filter(c => c.status === "warning");
489
+ const healable = checks.filter(c => c.healable && c.status !== "ok");
490
+ const result = {
491
+ timestamp: new Date().toISOString(),
492
+ summary: {
493
+ total: checks.length,
494
+ ok: checks.filter(c => c.status === "ok").length,
495
+ warnings: warnings.length,
496
+ criticals: criticals.length,
497
+ healable: healable.length,
498
+ },
499
+ checks,
500
+ };
501
+ if (healable.length > 0) {
502
+ result._hint = `${healable.length} issue(s) can be auto-fixed. Run run_self_heal to fix: ${healable.map(h => h.id).join(", ")}`;
503
+ }
504
+ if (args.include_history) {
505
+ result.recentWatchdogRuns = watchdogLog.slice(-10).map(e => ({
506
+ timestamp: e.timestamp,
507
+ healthScore: e.healthScore,
508
+ issues: e.checks.filter(c => c.status !== "ok").length,
509
+ healed: e.healed.length,
510
+ }));
511
+ }
512
+ return result;
513
+ },
514
+ },
515
+ {
516
+ name: "run_self_heal",
517
+ description: "Autonomous self-healing for detected drift issues. Fixes orphaned verification cycles " +
518
+ "(marks as abandoned), stale eval runs (marks as failed), and database bloat (prunes old logs, " +
519
+ "runs WAL checkpoint). Only fixes issues that are safe to auto-repair. " +
520
+ "Think of it like a house robot that cleans up messes — it only touches what it knows how to handle safely.",
521
+ inputSchema: {
522
+ type: "object",
523
+ properties: {
524
+ targets: {
525
+ type: "array",
526
+ items: { type: "string" },
527
+ description: "Specific drift IDs to heal (e.g. ['orphaned_cycles', 'stale_evals', 'db_size']). Empty = heal all.",
528
+ },
529
+ dry_run: {
530
+ type: "boolean",
531
+ description: "If true, report what would be healed without actually doing it. Default: false.",
532
+ },
533
+ },
534
+ required: [],
535
+ },
536
+ handler: async (args) => {
537
+ const db = getDb();
538
+ if (args.dry_run) {
539
+ const checks = runDriftChecks(db);
540
+ const healable = checks.filter(c => c.healable && c.status !== "ok");
541
+ const filtered = args.targets?.length
542
+ ? healable.filter(c => args.targets.includes(c.id))
543
+ : healable;
544
+ return {
545
+ dryRun: true,
546
+ wouldHeal: filtered.map(c => ({ id: c.id, name: c.name, detail: c.detail })),
547
+ _hint: filtered.length > 0 ? "Run again without dry_run=true to apply fixes." : "Nothing to heal.",
548
+ };
549
+ }
550
+ const healed = runSelfHeal(db, args.targets);
551
+ // Re-check after healing
552
+ const postChecks = runDriftChecks(db);
553
+ const remaining = postChecks.filter(c => c.status !== "ok");
554
+ return {
555
+ healed,
556
+ healedCount: healed.length,
557
+ remainingIssues: remaining.length,
558
+ postHealChecks: postChecks,
559
+ _hint: remaining.length > 0
560
+ ? `${remaining.length} issue(s) remain after healing. Some require manual intervention.`
561
+ : "All healable issues resolved.",
562
+ };
563
+ },
564
+ },
565
+ {
566
+ name: "get_uptime_stats",
567
+ description: "Get session uptime, tool call rates, error trends, and top tools over multiple time windows " +
568
+ "(5min, 1hr, 24hr, 7d). Includes error direction trend (increasing/decreasing/stable). " +
569
+ "Think of it like a car dashboard — speed, fuel level, and engine warning lights at a glance.",
570
+ inputSchema: {
571
+ type: "object",
572
+ properties: {},
573
+ required: [],
574
+ },
575
+ handler: async () => {
576
+ const db = getDb();
577
+ return computeUptimeStats(db);
578
+ },
579
+ },
580
+ {
581
+ name: "set_watchdog_config",
582
+ description: "Configure the background watchdog that continuously monitors system health. " +
583
+ "Set check interval, enable/disable, and adjust drift thresholds. Changes take effect immediately. " +
584
+ "Think of it like setting alarm sensitivity — too sensitive and you get noise, too loose and you miss real issues.",
585
+ inputSchema: {
586
+ type: "object",
587
+ properties: {
588
+ enabled: { type: "boolean", description: "Enable/disable the background watchdog" },
589
+ interval_minutes: { type: "number", description: "Check interval in minutes (min: 1, max: 60). Default: 5." },
590
+ thresholds: {
591
+ type: "object",
592
+ description: "Override drift detection thresholds",
593
+ properties: {
594
+ db_size_mb: { type: "number", description: "DB size warning threshold in MB (default: 500)" },
595
+ error_rate_percent: { type: "number", description: "Error rate warning threshold (default: 20)" },
596
+ stale_cache_hours: { type: "number", description: "Embedding cache staleness threshold in hours (default: 168)" },
597
+ orphaned_cycle_hours: { type: "number", description: "Orphaned cycle threshold in hours (default: 48)" },
598
+ },
599
+ },
600
+ },
601
+ required: [],
602
+ },
603
+ handler: async (args) => {
604
+ const prev = { ...watchdogConfig };
605
+ if (args.enabled !== undefined)
606
+ watchdogConfig.enabled = args.enabled;
607
+ if (args.interval_minutes !== undefined) {
608
+ const clamped = Math.max(1, Math.min(60, args.interval_minutes));
609
+ watchdogConfig.intervalMs = clamped * 60_000;
610
+ }
611
+ if (args.thresholds) {
612
+ if (args.thresholds.db_size_mb !== undefined)
613
+ watchdogConfig.thresholds.dbSizeMb = args.thresholds.db_size_mb;
614
+ if (args.thresholds.error_rate_percent !== undefined)
615
+ watchdogConfig.thresholds.errorRatePercent = args.thresholds.error_rate_percent;
616
+ if (args.thresholds.stale_cache_hours !== undefined)
617
+ watchdogConfig.thresholds.staleEmbeddingCacheHours = args.thresholds.stale_cache_hours;
618
+ if (args.thresholds.orphaned_cycle_hours !== undefined)
619
+ watchdogConfig.thresholds.orphanedCycleHours = args.thresholds.orphaned_cycle_hours;
620
+ }
621
+ // Restart watchdog if interval or enabled changed
622
+ if (watchdogConfig.enabled !== prev.enabled || watchdogConfig.intervalMs !== prev.intervalMs) {
623
+ stopWatchdog();
624
+ if (watchdogConfig.enabled && _getDb) {
625
+ startWatchdog(_getDb());
626
+ }
627
+ }
628
+ return {
629
+ config: watchdogConfig,
630
+ changes: {
631
+ enabled: prev.enabled !== watchdogConfig.enabled ? `${prev.enabled} → ${watchdogConfig.enabled}` : undefined,
632
+ intervalMs: prev.intervalMs !== watchdogConfig.intervalMs ? `${prev.intervalMs} → ${watchdogConfig.intervalMs}` : undefined,
633
+ },
634
+ };
635
+ },
636
+ },
637
+ {
638
+ name: "get_watchdog_log",
639
+ description: "Get recent watchdog check results. Shows health score trend, detected issues, and auto-healed " +
640
+ "actions over time. Useful for understanding system behavior patterns. " +
641
+ "Think of it like a security camera playback — see what happened while you weren't looking.",
642
+ inputSchema: {
643
+ type: "object",
644
+ properties: {
645
+ limit: { type: "number", description: "Number of recent entries to return (default: 20, max: 100)" },
646
+ only_issues: { type: "boolean", description: "Filter to only show entries with warnings or criticals. Default: false." },
647
+ },
648
+ required: [],
649
+ },
650
+ handler: async (args) => {
651
+ const limit = Math.min(args.limit ?? 20, 100);
652
+ let entries = watchdogLog.slice(-limit);
653
+ if (args.only_issues) {
654
+ entries = entries.filter(e => e.checks.some(c => c.status !== "ok"));
655
+ }
656
+ // Compute trend
657
+ const scores = entries.map(e => e.healthScore);
658
+ const avgScore = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 100;
659
+ const trend = scores.length >= 2
660
+ ? scores[scores.length - 1] > scores[0] ? "improving" : scores[scores.length - 1] < scores[0] ? "degrading" : "stable"
661
+ : "insufficient_data";
662
+ return {
663
+ entries: entries.map(e => ({
664
+ timestamp: e.timestamp,
665
+ healthScore: e.healthScore,
666
+ issues: e.checks.filter(c => c.status !== "ok").map(c => `[${c.status}] ${c.name}`),
667
+ healed: e.healed,
668
+ })),
669
+ summary: {
670
+ totalEntries: watchdogLog.length,
671
+ returned: entries.length,
672
+ avgHealthScore: Math.round(avgScore),
673
+ trend,
674
+ totalAutoHealed: watchdogLog.reduce((s, e) => s + e.healed.length, 0),
675
+ },
676
+ };
677
+ },
678
+ },
679
+ {
680
+ name: "get_sentinel_report",
681
+ description: "Get the latest sentinel self-test report with all 9 probe results (build, e2e, design, dogfood, " +
682
+ "voice, a11y, visual, performance, contract), root-cause diagnoses, and fix suggestions. " +
683
+ "The sentinel system is the 3-layer autonomous quality pipeline that tests the entire app surface.",
684
+ inputSchema: {
685
+ type: "object",
686
+ properties: {
687
+ probe_filter: {
688
+ type: "string",
689
+ description: "Comma-separated probes to include (e.g. 'build,e2e,voice'). Default: all.",
690
+ },
691
+ },
692
+ required: [],
693
+ },
694
+ handler: async (args) => {
695
+ const reportPaths = [
696
+ join(process.cwd(), ".sentinel", "latest.json"),
697
+ join(process.cwd(), "../../.sentinel/latest.json"),
698
+ ];
699
+ let report = null;
700
+ for (const p of reportPaths) {
701
+ if (existsSync(p)) {
702
+ try {
703
+ report = JSON.parse(readFileSync(p, "utf8"));
704
+ break;
705
+ }
706
+ catch { /* skip */ }
707
+ }
708
+ }
709
+ if (!report) {
710
+ return { available: false, hint: "Run 'npm run sentinel' to generate a health report." };
711
+ }
712
+ let probes = report.probes || [];
713
+ if (args.probe_filter) {
714
+ const filter = new Set(args.probe_filter.split(",").map((s) => s.trim().toLowerCase()));
715
+ probes = probes.filter((p) => filter.has(p.probe));
716
+ }
717
+ return {
718
+ reportId: report.id,
719
+ timestamp: report.timestamp,
720
+ summary: report.summary,
721
+ probes: probes.map((p) => ({
722
+ probe: p.probe, status: p.status, duration: p.duration,
723
+ summary: p.summary, failures: (p.failures || []).slice(0, 10),
724
+ })),
725
+ diagnoses: (report.diagnoses || []).slice(0, 15).map((d) => ({
726
+ probe: d.probe, severity: d.severity, symptom: d.symptom,
727
+ rootCause: d.rootCause, suggestedFix: d.suggestedFix,
728
+ })),
729
+ };
730
+ },
731
+ },
732
+ {
733
+ name: "get_observability_summary",
734
+ description: "Unified observability summary combining MCP system pulse, sentinel probes, watchdog status, " +
735
+ "and maintenance recommendations. Best single tool for a quick health check before starting work.",
736
+ inputSchema: {
737
+ type: "object",
738
+ properties: {},
739
+ required: [],
740
+ },
741
+ handler: async () => {
742
+ const db = getDb();
743
+ const pulse = await computeSystemPulse(db, 0);
744
+ const checks = runDriftChecks(db);
745
+ // Read sentinel report
746
+ const reportPaths = [
747
+ join(process.cwd(), ".sentinel", "latest.json"),
748
+ join(process.cwd(), "../../.sentinel/latest.json"),
749
+ ];
750
+ let sentinel = { available: false };
751
+ for (const p of reportPaths) {
752
+ if (existsSync(p)) {
753
+ try {
754
+ const r = JSON.parse(readFileSync(p, "utf8"));
755
+ sentinel = {
756
+ reportId: r.id,
757
+ age: r.timestamp ? `${Math.round((Date.now() - new Date(r.timestamp).getTime()) / 60000)}min` : "unknown",
758
+ summary: r.summary,
759
+ topIssues: (r.diagnoses || []).slice(0, 3).map((d) => `[${d.severity}] ${d.symptom}`),
760
+ };
761
+ break;
762
+ }
763
+ catch { /* skip */ }
764
+ }
765
+ }
766
+ const healable = checks.filter(c => c.healable && c.status !== "ok");
767
+ const nextActions = [];
768
+ if (pulse.healthScore < 70)
769
+ nextActions.push("System degraded — run get_drift_report then run_self_heal");
770
+ if (sentinel.available === false)
771
+ nextActions.push("Run 'npm run sentinel' for full app surface health check");
772
+ if (healable.length > 0)
773
+ nextActions.push(`${healable.length} auto-fixable issue(s) — run run_self_heal`);
774
+ if (nextActions.length === 0)
775
+ nextActions.push("All systems healthy.");
776
+ return {
777
+ timestamp: new Date().toISOString(),
778
+ mcpHealth: { score: pulse.healthScore, errors24h: pulse.recentErrors.last24hr, uptimeMs: pulse.uptime.durationMs },
779
+ driftChecks: { total: checks.length, ok: checks.filter(c => c.status === "ok").length, issues: checks.filter(c => c.status !== "ok").length },
780
+ sentinel,
781
+ watchdog: { running: !!watchdogTimer, logEntries: watchdogLog.length, lastScore: watchdogLog[watchdogLog.length - 1]?.healthScore ?? null },
782
+ nextActions,
783
+ };
784
+ },
785
+ },
786
+ ];
787
+ //# sourceMappingURL=observabilityTools.js.map