docs: enterprise refactor plan with ralph specs
This commit is contained in:
398
dist/server/SessionStore.js
vendored
Normal file
398
dist/server/SessionStore.js
vendored
Normal file
@@ -0,0 +1,398 @@
|
||||
"use strict";
|
||||
/**
|
||||
* SessionStore — manages active sessions and persists to SQLite.
|
||||
* In-memory map for running engines; DB for durable storage.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SessionStore = void 0;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const ExplorationEngine_1 = require("../core/ExplorationEngine");
|
||||
const StateGraph_1 = require("../core/StateGraph");
|
||||
const PlaywrightAgent_1 = require("../plugins/agents/PlaywrightAgent");
|
||||
const ScreenshotCollector_1 = require("../plugins/collectors/ScreenshotCollector");
|
||||
const NetworkCollector_1 = require("../plugins/collectors/NetworkCollector");
|
||||
const DOMSnapshotCollector_1 = require("../plugins/collectors/DOMSnapshotCollector");
|
||||
const MarkdownExporter_1 = require("../plugins/exporters/MarkdownExporter");
|
||||
const JSONExporter_1 = require("../plugins/exporters/JSONExporter");
|
||||
const PlaywrightReproducer_1 = require("../plugins/reproducers/PlaywrightReproducer");
|
||||
const FuzzingEngine_1 = require("../plugins/fuzzers/FuzzingEngine");
|
||||
const VisualRegressionCollector_1 = require("../plugins/collectors/VisualRegressionCollector");
|
||||
const AccessibilityCollector_1 = require("../plugins/collectors/AccessibilityCollector");
|
||||
const PerformanceCollector_1 = require("../plugins/collectors/PerformanceCollector");
|
||||
class SessionStore {
|
||||
constructor(outputDir = './reports', sessionRepo, anomalyRepo, maxConcurrentSessions = 3, notificationService, visualRepo) {
|
||||
this.sessions = new Map();
|
||||
this.emitter = () => undefined;
|
||||
/** In-memory performance metrics keyed by sessionId */
|
||||
this.performanceMetrics = new Map();
|
||||
this.outputDir = outputDir;
|
||||
this.sessionRepo = sessionRepo ?? null;
|
||||
this.anomalyRepo = anomalyRepo ?? null;
|
||||
this.maxConcurrentSessions = maxConcurrentSessions;
|
||||
this.notificationService = notificationService ?? null;
|
||||
this.visualRepo = visualRepo ?? null;
|
||||
}
|
||||
getPerformanceMetrics(sessionId) {
|
||||
return this.performanceMetrics.get(sessionId) ?? [];
|
||||
}
|
||||
getMaxConcurrent() {
|
||||
return this.maxConcurrentSessions;
|
||||
}
|
||||
setEmitter(emitter) {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
getAllSessions() {
|
||||
if (this.sessionRepo) {
|
||||
const rows = this.sessionRepo.findAll();
|
||||
return rows.map((r) => {
|
||||
const live = this.sessions.get(r.id);
|
||||
return {
|
||||
sessionId: r.id,
|
||||
url: r.url,
|
||||
seed: r.seed,
|
||||
maxStates: r.max_states,
|
||||
status: r.status,
|
||||
startedAt: new Date(r.started_at).toISOString(),
|
||||
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : undefined,
|
||||
statesVisited: r.states_visited,
|
||||
anomaliesFound: r.anomalies_found,
|
||||
anomalies: live?.anomalies ?? [],
|
||||
engine: live?.engine,
|
||||
};
|
||||
});
|
||||
}
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
getSession(sessionId) {
|
||||
if (this.sessionRepo) {
|
||||
const r = this.sessionRepo.findById(sessionId);
|
||||
if (!r)
|
||||
return undefined;
|
||||
const live = this.sessions.get(sessionId);
|
||||
return {
|
||||
sessionId: r.id,
|
||||
url: r.url,
|
||||
seed: r.seed,
|
||||
maxStates: r.max_states,
|
||||
status: r.status,
|
||||
startedAt: new Date(r.started_at).toISOString(),
|
||||
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : undefined,
|
||||
statesVisited: r.states_visited,
|
||||
anomaliesFound: r.anomalies_found,
|
||||
anomalies: live?.anomalies ?? [],
|
||||
engine: live?.engine,
|
||||
};
|
||||
}
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
getAllAnomalies(sessionId, severity) {
|
||||
if (this.anomalyRepo) {
|
||||
return this.anomalyRepo.findAll({ sessionId, severity });
|
||||
}
|
||||
const all = Array.from(this.sessions.values()).flatMap((s) => s.anomalies);
|
||||
return all
|
||||
.filter((a) => !sessionId || this.findSessionForAnomaly(a.id) === sessionId)
|
||||
.filter((a) => !severity || a.severity === severity);
|
||||
}
|
||||
getAnomaly(anomalyId) {
|
||||
if (this.anomalyRepo) {
|
||||
return this.anomalyRepo.findById(anomalyId) ?? undefined;
|
||||
}
|
||||
for (const session of this.sessions.values()) {
|
||||
const found = session.anomalies.find((a) => a.id === anomalyId);
|
||||
if (found)
|
||||
return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
findSessionForAnomaly(anomalyId) {
|
||||
if (this.anomalyRepo) {
|
||||
const a = this.anomalyRepo.findById(anomalyId);
|
||||
return a?.sessionId;
|
||||
}
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.anomalies.some((a) => a.id === anomalyId))
|
||||
return session.sessionId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
screenshotPath(anomalyId) {
|
||||
const anomaly = this.getAnomaly(anomalyId);
|
||||
if (!anomaly?.evidence.screenshotPath)
|
||||
return undefined;
|
||||
const sessionId = this.findSessionForAnomaly(anomalyId);
|
||||
if (!sessionId)
|
||||
return undefined;
|
||||
return path_1.default.resolve(this.outputDir, anomalyId, anomaly.evidence.screenshotPath);
|
||||
}
|
||||
stopSession(sessionId) {
|
||||
const record = this.sessions.get(sessionId);
|
||||
if (!record || record.status !== 'running')
|
||||
return false;
|
||||
record.engine?.stop();
|
||||
record.status = 'stopped';
|
||||
record.finishedAt = new Date().toISOString();
|
||||
this.sessionRepo?.update(sessionId, { status: 'stopped', finishedAt: Date.now() });
|
||||
return true;
|
||||
}
|
||||
getStats() {
|
||||
if (this.sessionRepo && this.anomalyRepo) {
|
||||
const rows = this.sessionRepo.findAll();
|
||||
return {
|
||||
totalSessions: rows.length,
|
||||
totalAnomalies: this.anomalyRepo.count(),
|
||||
criticalHighCount: this.anomalyRepo.countBySeverity(['high', 'critical']),
|
||||
runningSessions: rows.filter((r) => r.status === 'running').length,
|
||||
};
|
||||
}
|
||||
const sessions = Array.from(this.sessions.values());
|
||||
const anomalies = sessions.flatMap((s) => s.anomalies);
|
||||
return {
|
||||
totalSessions: sessions.length,
|
||||
totalAnomalies: anomalies.length,
|
||||
criticalHighCount: anomalies.filter((a) => a.severity === 'high' || a.severity === 'critical').length,
|
||||
runningSessions: sessions.filter((s) => s.status === 'running').length,
|
||||
};
|
||||
}
|
||||
async startSession(params) {
|
||||
const sessionId = `sess_${Date.now()}_${params.seed}`;
|
||||
const startedAt = new Date().toISOString();
|
||||
const startedAtMs = Date.now();
|
||||
const record = {
|
||||
sessionId,
|
||||
url: params.url,
|
||||
seed: params.seed,
|
||||
maxStates: params.maxStates,
|
||||
status: 'running',
|
||||
startedAt,
|
||||
statesVisited: 0,
|
||||
anomaliesFound: 0,
|
||||
anomalies: [],
|
||||
};
|
||||
this.sessions.set(sessionId, record);
|
||||
this.sessionRepo?.create({
|
||||
id: sessionId,
|
||||
url: params.url,
|
||||
seed: params.seed,
|
||||
maxStates: params.maxStates,
|
||||
startedAt: startedAtMs,
|
||||
configJson: params.explorationConfig ? JSON.stringify(params.explorationConfig) : '{}',
|
||||
});
|
||||
this.emitter('session:started', { sessionId, url: params.url });
|
||||
const graph = new StateGraph_1.StateGraph();
|
||||
const agent = new PlaywrightAgent_1.PlaywrightAgent({
|
||||
seed: params.seed,
|
||||
explorationConfig: params.explorationConfig,
|
||||
});
|
||||
const fuzzingEnabled = params.explorationConfig?.fuzzingEnabled !== false;
|
||||
const fuzzingIntensity = params.explorationConfig?.fuzzingIntensity ?? 'medium';
|
||||
const fuzzingPlugin = fuzzingEnabled
|
||||
? new FuzzingEngine_1.FuzzingEngine({ intensity: fuzzingIntensity, seed: params.seed })
|
||||
: undefined;
|
||||
// Build state hooks for visual regression, accessibility, and performance
|
||||
const stateHooks = [];
|
||||
// Visual regression hook
|
||||
if (params.explorationConfig?.visualRegression?.enabled && this.visualRepo) {
|
||||
const visualCollector = new VisualRegressionCollector_1.VisualRegressionCollector(this.outputDir, this.visualRepo, params.explorationConfig.visualRegression);
|
||||
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
|
||||
const pw = agentInstance;
|
||||
if (!pw.getPage)
|
||||
return [];
|
||||
// Take screenshot for visual comparison
|
||||
const screenshotPath = path_1.default.join(this.outputDir, sid, `visual_${state.id}.png`);
|
||||
try {
|
||||
const fs_mod = await Promise.resolve().then(() => __importStar(require('fs/promises')));
|
||||
await fs_mod.mkdir(path_1.default.dirname(screenshotPath), { recursive: true });
|
||||
await pw.getPage().screenshot({ path: screenshotPath });
|
||||
const anomaly = await visualCollector.processScreenshot(screenshotPath, state, sid, actionTrace);
|
||||
return anomaly ? [anomaly] : [];
|
||||
}
|
||||
catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
// Accessibility hook
|
||||
if (params.explorationConfig?.accessibility?.enabled !== false) {
|
||||
const a11yCollector = new AccessibilityCollector_1.AccessibilityCollector(params.explorationConfig?.accessibility);
|
||||
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
|
||||
const pw = agentInstance;
|
||||
if (!pw.getPage)
|
||||
return [];
|
||||
return a11yCollector.collect(pw.getPage(), state.id, sid, actionTrace);
|
||||
});
|
||||
}
|
||||
// Performance hook
|
||||
if (params.explorationConfig?.performance?.enabled !== false) {
|
||||
const perfCollector = new PerformanceCollector_1.PerformanceCollector(params.explorationConfig?.performance);
|
||||
this.performanceMetrics.set(sessionId, []);
|
||||
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
|
||||
const pw = agentInstance;
|
||||
if (!pw.getPage)
|
||||
return [];
|
||||
const { metrics, anomalies } = await perfCollector.collect(pw.getPage(), state.id, sid, actionTrace);
|
||||
const existing = this.performanceMetrics.get(sid) ?? [];
|
||||
existing.push(metrics);
|
||||
this.performanceMetrics.set(sid, existing);
|
||||
return anomalies;
|
||||
});
|
||||
}
|
||||
// Mobile layout hook (only when a mobile device is emulated)
|
||||
if (params.explorationConfig?.mobileDevice && params.explorationConfig.mobileDevice !== 'none') {
|
||||
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
|
||||
const pw = agentInstance;
|
||||
if (!pw.detectMobileLayoutIssues)
|
||||
return [];
|
||||
return pw.detectMobileLayoutIssues(state.id, sid, actionTrace);
|
||||
});
|
||||
}
|
||||
const engineConfig = {
|
||||
graph,
|
||||
agent,
|
||||
seed: params.seed,
|
||||
url: params.url,
|
||||
maxSteps: params.maxStates,
|
||||
explorationConfig: params.explorationConfig,
|
||||
outputDir: this.outputDir,
|
||||
sessionId,
|
||||
fuzzingPlugin,
|
||||
stateHooks,
|
||||
collectors: [
|
||||
new ScreenshotCollector_1.ScreenshotCollector(this.outputDir),
|
||||
new NetworkCollector_1.NetworkCollector(),
|
||||
new DOMSnapshotCollector_1.DOMSnapshotCollector(this.outputDir),
|
||||
],
|
||||
exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()],
|
||||
reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(),
|
||||
events: {
|
||||
onSessionStarted: (sid, url) => {
|
||||
this.emitter('session:started', { sessionId: sid, url });
|
||||
},
|
||||
onStateDiscovered: (sid, stateId, url, title) => {
|
||||
record.statesVisited += 1;
|
||||
this.sessionRepo?.update(sid, { statesVisited: record.statesVisited });
|
||||
this.emitter('state:discovered', { sessionId: sid, stateId, url, title });
|
||||
},
|
||||
onActionExecuted: (sid, actionType, selector, timestamp) => {
|
||||
this.emitter('action:executed', { sessionId: sid, actionType, selector, timestamp });
|
||||
},
|
||||
onAnomalyDetected: (sid, anomaly) => {
|
||||
record.anomalies.push(anomaly);
|
||||
record.anomaliesFound = record.anomalies.length;
|
||||
this.sessionRepo?.update(sid, { anomaliesFound: record.anomaliesFound });
|
||||
this.anomalyRepo?.create(anomaly, sid);
|
||||
if (this.notificationService) {
|
||||
void this.notificationService.notify(anomaly, sid, params.url);
|
||||
}
|
||||
this.emitter('anomaly:detected', {
|
||||
sessionId: sid,
|
||||
anomalyId: anomaly.id,
|
||||
type: anomaly.type,
|
||||
severity: anomaly.severity,
|
||||
description: anomaly.description,
|
||||
});
|
||||
},
|
||||
onSessionCompleted: (sid, statesVisited, anomaliesFound) => {
|
||||
if (record.status === 'running') {
|
||||
record.status = 'completed';
|
||||
}
|
||||
record.finishedAt = new Date().toISOString();
|
||||
record.statesVisited = statesVisited;
|
||||
record.anomaliesFound = anomaliesFound;
|
||||
this.sessionRepo?.update(sid, {
|
||||
status: record.status,
|
||||
statesVisited,
|
||||
anomaliesFound,
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
this.emitter('session:completed', { sessionId: sid, statesVisited, anomaliesFound });
|
||||
},
|
||||
onSessionError: (sid, error) => {
|
||||
record.status = 'error';
|
||||
record.finishedAt = new Date().toISOString();
|
||||
this.sessionRepo?.update(sid, { status: 'error', finishedAt: Date.now() });
|
||||
this.emitter('session:error', { sessionId: sid, error });
|
||||
},
|
||||
},
|
||||
};
|
||||
const engine = new ExplorationEngine_1.ExplorationEngine(engineConfig);
|
||||
record.engine = engine;
|
||||
// Run in background — do not await
|
||||
engine.run().catch((err) => {
|
||||
if (record.status === 'running') {
|
||||
record.status = 'error';
|
||||
record.finishedAt = new Date().toISOString();
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.sessionRepo?.update(sessionId, { status: 'error', finishedAt: Date.now() });
|
||||
this.emitter('session:error', { sessionId, error: msg });
|
||||
}
|
||||
});
|
||||
return record;
|
||||
}
|
||||
async replayAnomaly(anomalyId) {
|
||||
const replayId = `replay_${Date.now()}`;
|
||||
const anomaly = this.getAnomaly(anomalyId);
|
||||
if (!anomaly)
|
||||
throw new Error(`Anomaly ${anomalyId} not found`);
|
||||
const sessionId = this.findSessionForAnomaly(anomalyId);
|
||||
const session = this.getSession(sessionId);
|
||||
const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer();
|
||||
const script = reproducer.generateScript(anomaly.actionTrace);
|
||||
const replayDir = path_1.default.join(this.outputDir, anomalyId, 'replays');
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const fs_mod = await Promise.resolve().then(() => __importStar(require('fs/promises')));
|
||||
await fs_mod.mkdir(replayDir, { recursive: true });
|
||||
const scriptPath = path_1.default.join(replayDir, `${replayId}.ts`);
|
||||
await fs_mod.writeFile(scriptPath, script, 'utf8');
|
||||
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed: session.seed });
|
||||
await agent.launch(session.url);
|
||||
for (const action of anomaly.actionTrace) {
|
||||
await agent.executeAction(action).catch(() => undefined);
|
||||
}
|
||||
await agent.close();
|
||||
}
|
||||
catch {
|
||||
// replay errors are non-fatal
|
||||
}
|
||||
});
|
||||
return replayId;
|
||||
}
|
||||
}
|
||||
exports.SessionStore = SessionStore;
|
||||
71
dist/server/enrichment/AIEnrichmentService.js
vendored
Normal file
71
dist/server/enrichment/AIEnrichmentService.js
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
/**
|
||||
* AIEnrichmentService — selects AI provider and runs enrichment asynchronously.
|
||||
* Triggered manually (POST /api/anomalies/:id/enrich) or automatically for high/critical.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AIEnrichmentService = void 0;
|
||||
const ClaudeProvider_1 = require("./ClaudeProvider");
|
||||
const OpenAIProvider_1 = require("./OpenAIProvider");
|
||||
const OllamaProvider_1 = require("./OllamaProvider");
|
||||
const logger_1 = require("../logger");
|
||||
class AIEnrichmentService {
|
||||
constructor(emitter) {
|
||||
this.emitter = emitter;
|
||||
this.autoEnrich = process.env['ABE_AI_AUTO_ENRICH'] === 'true';
|
||||
const minSev = process.env['ABE_AI_MIN_SEVERITY'] ?? 'high';
|
||||
this.minSeverityRank = AIEnrichmentService.SEVERITY_RANK[minSev] ?? 2;
|
||||
this.provider = this.createProvider();
|
||||
}
|
||||
createProvider() {
|
||||
const providerName = process.env['ABE_AI_PROVIDER'] ?? 'none';
|
||||
const model = process.env['ABE_AI_MODEL'];
|
||||
if (providerName === 'claude') {
|
||||
const key = process.env['ABE_AI_API_KEY'];
|
||||
if (!key)
|
||||
return null;
|
||||
return new ClaudeProvider_1.ClaudeProvider(key, model);
|
||||
}
|
||||
if (providerName === 'openai') {
|
||||
const key = process.env['ABE_OPENAI_API_KEY'];
|
||||
if (!key)
|
||||
return null;
|
||||
return new OpenAIProvider_1.OpenAIProvider(key, model);
|
||||
}
|
||||
if (providerName === 'ollama') {
|
||||
const url = process.env['ABE_OLLAMA_URL'] ?? 'http://localhost:11434';
|
||||
return new OllamaProvider_1.OllamaProvider(url, model);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** Check if auto-enrichment should run for this anomaly. */
|
||||
shouldAutoEnrich(anomaly) {
|
||||
if (!this.autoEnrich || !this.provider)
|
||||
return false;
|
||||
const rank = AIEnrichmentService.SEVERITY_RANK[anomaly.severity] ?? 0;
|
||||
return rank >= this.minSeverityRank;
|
||||
}
|
||||
/** Enrich an anomaly asynchronously and emit WebSocket event when done. */
|
||||
async enrich(anomaly, context) {
|
||||
if (!this.provider) {
|
||||
logger_1.log.warn({ anomalyId: anomaly.id }, 'No AI provider configured');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const enrichment = await this.provider.enrich(anomaly, context);
|
||||
anomaly.aiEnrichment = enrichment;
|
||||
this.emitter('anomaly:enriched', { anomalyId: anomaly.id, enrichment });
|
||||
logger_1.log.info({ anomalyId: anomaly.id, provider: this.provider.name }, 'Anomaly enriched');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.log.error({ anomalyId: anomaly.id, err: err instanceof Error ? err.message : String(err) }, 'AI enrichment failed');
|
||||
}
|
||||
}
|
||||
hasProvider() {
|
||||
return this.provider !== null;
|
||||
}
|
||||
}
|
||||
exports.AIEnrichmentService = AIEnrichmentService;
|
||||
AIEnrichmentService.SEVERITY_RANK = {
|
||||
low: 0, medium: 1, high: 2, critical: 3,
|
||||
};
|
||||
88
dist/server/enrichment/ClaudeProvider.js
vendored
Normal file
88
dist/server/enrichment/ClaudeProvider.js
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
"use strict";
|
||||
/**
|
||||
* ClaudeProvider — AI enrichment using Anthropic API.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ClaudeProvider = void 0;
|
||||
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
|
||||
class ClaudeProvider {
|
||||
constructor(apiKey, model = DEFAULT_MODEL) {
|
||||
this.name = 'claude';
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
}
|
||||
async enrich(anomaly, context) {
|
||||
const prompt = buildPrompt(anomaly, context);
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const text = data.content.find((c) => c.type === 'text')?.text ?? '';
|
||||
return parseEnrichment(text, this.name, this.model);
|
||||
}
|
||||
}
|
||||
exports.ClaudeProvider = ClaudeProvider;
|
||||
function buildPrompt(anomaly, context) {
|
||||
return `You are a senior software engineer analyzing a bug report from an automated web testing tool.
|
||||
|
||||
Bug Report:
|
||||
- Type: ${anomaly.type}
|
||||
- Severity: ${anomaly.severity}
|
||||
- Description: ${anomaly.description}
|
||||
- URL: ${context.url}
|
||||
- Page Title: ${context.pageTitle}
|
||||
- Action Trace: ${JSON.stringify(anomaly.actionTrace.slice(-5), null, 2)}
|
||||
${context.httpLog.length > 0 ? `- HTTP Log: ${JSON.stringify(context.httpLog.slice(-3), null, 2)}` : ''}
|
||||
${context.consoleErrors.length > 0 ? `- Console Errors: ${context.consoleErrors.slice(-3).join('\n')}` : ''}
|
||||
|
||||
Please provide a concise analysis in exactly this JSON format:
|
||||
{
|
||||
"rootCause": "One sentence explaining the likely root cause",
|
||||
"userImpact": "One sentence describing the impact on users",
|
||||
"suggestedFix": "One to two sentences with a concrete fix suggestion",
|
||||
"confidence": "low|medium|high"
|
||||
}`;
|
||||
}
|
||||
function parseEnrichment(text, provider, model) {
|
||||
const debugPrompt = `Bug analysis:\n${text}`;
|
||||
try {
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
return {
|
||||
rootCause: parsed.rootCause ?? 'Unknown root cause',
|
||||
userImpact: parsed.userImpact ?? 'Unknown impact',
|
||||
suggestedFix: parsed.suggestedFix ?? 'No fix suggested',
|
||||
debugPrompt,
|
||||
confidence: parsed.confidence ?? 'medium',
|
||||
generatedAt: Date.now(),
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch { /* fallback below */ }
|
||||
return {
|
||||
rootCause: text.slice(0, 200) || 'Could not parse root cause',
|
||||
userImpact: 'See full response',
|
||||
suggestedFix: 'See full response',
|
||||
debugPrompt,
|
||||
confidence: 'low',
|
||||
generatedAt: Date.now(),
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
}
|
||||
63
dist/server/enrichment/OllamaProvider.js
vendored
Normal file
63
dist/server/enrichment/OllamaProvider.js
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
/**
|
||||
* OllamaProvider — AI enrichment using local Ollama API.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.OllamaProvider = void 0;
|
||||
const DEFAULT_MODEL = 'llama3.2';
|
||||
const DEFAULT_URL = 'http://localhost:11434';
|
||||
function buildPrompt(anomaly, context) {
|
||||
return `Analyze this bug and respond ONLY with JSON {"rootCause":"...","userImpact":"...","suggestedFix":"...","confidence":"low|medium|high"}.
|
||||
|
||||
Bug: ${anomaly.type} (${anomaly.severity}) at ${context.url}
|
||||
Description: ${anomaly.description}
|
||||
Last actions: ${anomaly.actionTrace.slice(-3).map((a) => `${a.type} ${a.selector ?? a.url ?? ''}`).join(' → ')}`;
|
||||
}
|
||||
class OllamaProvider {
|
||||
constructor(baseUrl = DEFAULT_URL, model = DEFAULT_MODEL) {
|
||||
this.name = 'ollama';
|
||||
this.baseUrl = baseUrl;
|
||||
this.model = model;
|
||||
}
|
||||
async enrich(anomaly, context) {
|
||||
const prompt = buildPrompt(anomaly, context);
|
||||
const res = await fetch(`${this.baseUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: this.model, prompt, stream: false }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ollama API error: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const text = data.response ?? '';
|
||||
try {
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
const p = JSON.parse(match[0]);
|
||||
return {
|
||||
rootCause: p['rootCause'] ?? 'Unknown',
|
||||
userImpact: p['userImpact'] ?? 'Unknown',
|
||||
suggestedFix: p['suggestedFix'] ?? 'None',
|
||||
debugPrompt: text,
|
||||
confidence: p['confidence'] ?? 'low',
|
||||
generatedAt: Date.now(),
|
||||
provider: 'ollama',
|
||||
model: this.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch { /* fallback */ }
|
||||
return {
|
||||
rootCause: text.slice(0, 200),
|
||||
userImpact: 'See response',
|
||||
suggestedFix: 'See response',
|
||||
debugPrompt: text,
|
||||
confidence: 'low',
|
||||
generatedAt: Date.now(),
|
||||
provider: 'ollama',
|
||||
model: this.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
exports.OllamaProvider = OllamaProvider;
|
||||
81
dist/server/enrichment/OpenAIProvider.js
vendored
Normal file
81
dist/server/enrichment/OpenAIProvider.js
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
"use strict";
|
||||
/**
|
||||
* OpenAIProvider — AI enrichment using OpenAI API.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.OpenAIProvider = void 0;
|
||||
const DEFAULT_MODEL = 'gpt-4o-mini';
|
||||
function buildPrompt(anomaly, context) {
|
||||
return `You are a senior software engineer analyzing a bug report.
|
||||
|
||||
Bug:
|
||||
- Type: ${anomaly.type}
|
||||
- Severity: ${anomaly.severity}
|
||||
- Description: ${anomaly.description}
|
||||
- URL: ${context.url}
|
||||
- Actions: ${JSON.stringify(anomaly.actionTrace.slice(-5))}
|
||||
${context.httpLog.length > 0 ? `- HTTP: ${JSON.stringify(context.httpLog.slice(-3))}` : ''}
|
||||
${context.consoleErrors.length > 0 ? `- Errors: ${context.consoleErrors.slice(-3).join('; ')}` : ''}
|
||||
|
||||
Respond ONLY with JSON:
|
||||
{"rootCause":"...","userImpact":"...","suggestedFix":"...","confidence":"low|medium|high"}`;
|
||||
}
|
||||
function parseResponse(text, model) {
|
||||
try {
|
||||
const match = text.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
const p = JSON.parse(match[0]);
|
||||
return {
|
||||
rootCause: p['rootCause'] ?? 'Unknown',
|
||||
userImpact: p['userImpact'] ?? 'Unknown',
|
||||
suggestedFix: p['suggestedFix'] ?? 'None',
|
||||
debugPrompt: text,
|
||||
confidence: p['confidence'] ?? 'medium',
|
||||
generatedAt: Date.now(),
|
||||
provider: 'openai',
|
||||
model,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch { /* fallback */ }
|
||||
return {
|
||||
rootCause: text.slice(0, 200),
|
||||
userImpact: 'See response',
|
||||
suggestedFix: 'See response',
|
||||
debugPrompt: text,
|
||||
confidence: 'low',
|
||||
generatedAt: Date.now(),
|
||||
provider: 'openai',
|
||||
model,
|
||||
};
|
||||
}
|
||||
class OpenAIProvider {
|
||||
constructor(apiKey, model = DEFAULT_MODEL) {
|
||||
this.name = 'openai';
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
}
|
||||
async enrich(anomaly, context) {
|
||||
const prompt = buildPrompt(anomaly, context);
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 512,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenAI API error: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const text = data.choices[0]?.message?.content ?? '';
|
||||
return parseResponse(text, this.model);
|
||||
}
|
||||
}
|
||||
exports.OpenAIProvider = OpenAIProvider;
|
||||
199
dist/server/index.js
vendored
Normal file
199
dist/server/index.js
vendored
Normal file
@@ -0,0 +1,199 @@
|
||||
"use strict";
|
||||
/**
|
||||
* ABE API Server
|
||||
* Express + socket.io on port 3001.
|
||||
* Manages exploration sessions and serves REST + WebSocket API.
|
||||
*/
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createApp = createApp;
|
||||
exports.createServer = createServer;
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const http_1 = __importDefault(require("http"));
|
||||
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
||||
const socket_io_1 = require("socket.io");
|
||||
const sessions_1 = require("./routes/sessions");
|
||||
const anomalies_1 = require("./routes/anomalies");
|
||||
const config_1 = require("./routes/config");
|
||||
const schedules_1 = require("./routes/schedules");
|
||||
const visual_1 = require("./routes/visual");
|
||||
const SessionStore_1 = require("./SessionStore");
|
||||
const auth_1 = require("./middleware/auth");
|
||||
const logger_1 = require("./logger");
|
||||
const AIEnrichmentService_1 = require("./enrichment/AIEnrichmentService");
|
||||
const PORT = process.env['ABE_PORT']
|
||||
? parseInt(process.env['ABE_PORT'], 10)
|
||||
: process.env['PORT']
|
||||
? parseInt(process.env['PORT'], 10)
|
||||
: 3001;
|
||||
function createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService) {
|
||||
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
|
||||
const app = (0, express_1.default)();
|
||||
app.use((0, cors_1.default)({ origin: corsOrigin }));
|
||||
app.use(express_1.default.json());
|
||||
// Health endpoints — no auth required
|
||||
app.get('/health', (_req, res) => {
|
||||
const uptime = Math.floor(process.uptime());
|
||||
res.json({ status: 'ok', version: '0.1.0', uptime_seconds: uptime });
|
||||
});
|
||||
app.get('/ready', (_req, res) => {
|
||||
const stats = store.getStats();
|
||||
if (dbCheck && !dbCheck()) {
|
||||
res.status(503).json({ status: 'not_ready', db: 'disconnected', active_sessions: stats.runningSessions });
|
||||
return;
|
||||
}
|
||||
res.json({ status: 'ready', db: 'connected', active_sessions: stats.runningSessions });
|
||||
});
|
||||
// Apply API key auth to all /api/* routes
|
||||
app.use('/api', auth_1.apiKeyAuth);
|
||||
// Global rate limit: 200 req/min
|
||||
const globalLimiter = (0, express_rate_limit_1.default)({
|
||||
windowMs: 60 * 1000,
|
||||
max: 200,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
app.use('/api', globalLimiter);
|
||||
// POST /api/sessions rate limit: 20/hour
|
||||
const sessionCreateLimiter = (0, express_rate_limit_1.default)({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
app.post('/api/sessions', sessionCreateLimiter);
|
||||
app.get('/api/stats', (_req, res) => {
|
||||
res.json(store.getStats());
|
||||
});
|
||||
app.use('/api/sessions', (0, sessions_1.createSessionRouter)(store));
|
||||
app.use('/api/anomalies', (0, anomalies_1.createAnomalyRouter)(store, enrichmentService));
|
||||
app.use('/api/config', (0, config_1.createConfigRouter)());
|
||||
if (scheduleRepo && scheduler) {
|
||||
app.use('/api/schedules', (0, schedules_1.createScheduleRouter)(scheduleRepo, scheduler));
|
||||
}
|
||||
if (visualRepo) {
|
||||
app.use('/api/visual', (0, visual_1.createVisualRouter)(visualRepo));
|
||||
}
|
||||
// Global error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
const isDev = process.env['NODE_ENV'] !== 'production';
|
||||
const message = isDev && err instanceof Error ? err.message : 'Internal server error';
|
||||
res.status(500).json({
|
||||
error: message,
|
||||
code: 'INTERNAL_ERROR',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
return app;
|
||||
}
|
||||
function createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo) {
|
||||
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
|
||||
// Deferred emitter: AIEnrichmentService is created before io, using a closure
|
||||
let ioEmit = () => undefined;
|
||||
const enrichmentService = new AIEnrichmentService_1.AIEnrichmentService((event, payload) => ioEmit(event, payload));
|
||||
const app = createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService);
|
||||
const httpServer = http_1.default.createServer(app);
|
||||
const io = new socket_io_1.Server(httpServer, {
|
||||
cors: { origin: corsOrigin },
|
||||
});
|
||||
// Now wire the real io emitter
|
||||
ioEmit = (event, payload) => io.emit(event, payload);
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('session:stop', (data) => {
|
||||
store.stopSession(data.sessionId);
|
||||
});
|
||||
});
|
||||
store.setEmitter((event, payload) => {
|
||||
io.emit(event, payload);
|
||||
// Auto-enrich high/critical anomalies
|
||||
if (event === 'anomaly:detected') {
|
||||
const p = payload;
|
||||
if (p?.anomalyId) {
|
||||
const anomaly = store.getAnomaly(p.anomalyId);
|
||||
if (anomaly && enrichmentService.shouldAutoEnrich(anomaly)) {
|
||||
const context = {
|
||||
domSnapshot: '',
|
||||
httpLog: anomaly.evidence.httpLog ?? [],
|
||||
consoleErrors: anomaly.evidence.rawErrors ?? [],
|
||||
actionTrace: anomaly.actionTrace,
|
||||
pageTitle: '',
|
||||
url: anomaly.actionTrace[anomaly.actionTrace.length - 1]?.url ?? '',
|
||||
};
|
||||
void enrichmentService.enrich(anomaly, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return httpServer;
|
||||
}
|
||||
if (require.main === module) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { getDb } = require('../db/connection');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { SessionRepository } = require('../db/SessionRepository');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { AnomalyRepository } = require('../db/AnomalyRepository');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { NotificationService } = require('./notifications/NotificationService');
|
||||
const db = getDb();
|
||||
const sessionRepo = new SessionRepository(db);
|
||||
const anomalyRepo = new AnomalyRepository(db);
|
||||
const notificationService = new NotificationService({
|
||||
persister: (record) => {
|
||||
db.prepare(`INSERT OR REPLACE INTO notifications (id, anomaly_id, channel, status, sent_at, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run(record.id, record.anomalyId, record.channel, record.status, record.sentAt ?? null, record.error ?? null);
|
||||
},
|
||||
});
|
||||
const outputDir = process.env['ABE_REPORTS_DIR'] ?? './reports';
|
||||
const maxConcurrent = process.env['ABE_MAX_CONCURRENT_SESSIONS']
|
||||
? parseInt(process.env['ABE_MAX_CONCURRENT_SESSIONS'], 10)
|
||||
: 3;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { VisualBaselineRepository: VisualRepo } = require('../db/VisualBaselineRepository');
|
||||
const visualRepo = new VisualRepo(db);
|
||||
const store = new SessionStore_1.SessionStore(outputDir, sessionRepo, anomalyRepo, maxConcurrent, notificationService, visualRepo);
|
||||
const dbCheck = () => { try {
|
||||
db.prepare('SELECT 1').run();
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
} };
|
||||
// Scheduler
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ScheduleRepository: SchedRepo } = require('../db/ScheduleRepository');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { SchedulerService: SchedSvc } = require('./scheduler/SchedulerService');
|
||||
const scheduleRepo = new SchedRepo(db);
|
||||
const scheduler = new SchedSvc(scheduleRepo, store);
|
||||
scheduler.start();
|
||||
const server = createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo);
|
||||
// Graceful shutdown
|
||||
let shuttingDown = false;
|
||||
function shutdown(signal) {
|
||||
if (shuttingDown)
|
||||
return;
|
||||
shuttingDown = true;
|
||||
logger_1.log.info({ signal }, 'Graceful shutdown initiated');
|
||||
scheduler.stop();
|
||||
server.close(() => {
|
||||
try {
|
||||
db.close();
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => {
|
||||
logger_1.log.error('Forced shutdown after 30s');
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
server.listen(PORT, () => {
|
||||
logger_1.log.info({ port: PORT }, 'ABE API server listening');
|
||||
});
|
||||
}
|
||||
13
dist/server/logger.js
vendored
Normal file
13
dist/server/logger.js
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Structured logger using pino.
|
||||
* Log level configurable via ABE_LOG_LEVEL env var (default: 'info').
|
||||
*/
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.log = void 0;
|
||||
const pino_1 = __importDefault(require("pino"));
|
||||
const level = process.env['ABE_LOG_LEVEL'] ?? 'info';
|
||||
exports.log = (0, pino_1.default)({ level });
|
||||
21
dist/server/middleware/auth.js
vendored
Normal file
21
dist/server/middleware/auth.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
/**
|
||||
* API Key authentication middleware.
|
||||
* Reads ABE_API_KEY env var; if not set, dev mode (no auth).
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.apiKeyAuth = apiKeyAuth;
|
||||
function apiKeyAuth(req, res, next) {
|
||||
const apiKey = process.env['ABE_API_KEY'];
|
||||
if (!apiKey) {
|
||||
// Dev mode: no auth required
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const provided = req.headers['x-abe-api-key'];
|
||||
if (!provided || provided !== apiKey) {
|
||||
res.status(401).json({ error: 'Invalid or missing API key' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
121
dist/server/notifications/NotificationService.js
vendored
Normal file
121
dist/server/notifications/NotificationService.js
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
"use strict";
|
||||
/**
|
||||
* NotificationService — orchestrates notifiers.
|
||||
* Called after every anomaly:detected event.
|
||||
* Persists notification attempts to the notifications table.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.NotificationService = void 0;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const SlackNotifier_1 = require("./SlackNotifier");
|
||||
const WebhookNotifier_1 = require("./WebhookNotifier");
|
||||
const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
||||
class NotificationService {
|
||||
constructor(config) {
|
||||
const slackUrl = config?.slackWebhookUrl ?? process.env['ABE_SLACK_WEBHOOK_URL'];
|
||||
const webhookUrl = config?.webhookUrl ?? process.env['ABE_WEBHOOK_URL'];
|
||||
const minSeverity = config?.minSeverity ?? process.env['ABE_NOTIFY_MIN_SEVERITY'] ?? 'high';
|
||||
const frontendBase = config?.frontendBaseUrl ?? process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
|
||||
if (slackUrl)
|
||||
this.slack = new SlackNotifier_1.SlackNotifier(slackUrl, frontendBase);
|
||||
if (webhookUrl)
|
||||
this.webhook = new WebhookNotifier_1.WebhookNotifier(webhookUrl);
|
||||
this.minSeverityRank = SEVERITY_RANK[minSeverity] ?? 2;
|
||||
this.persister = config?.persister;
|
||||
}
|
||||
async notify(anomaly, sessionId, targetUrl) {
|
||||
const anomalySeverityRank = SEVERITY_RANK[anomaly.severity] ?? 0;
|
||||
if (anomalySeverityRank < this.minSeverityRank)
|
||||
return;
|
||||
const sends = [];
|
||||
if (this.slack) {
|
||||
sends.push(this.sendWithRetry('slack', anomaly, sessionId, targetUrl));
|
||||
}
|
||||
if (this.webhook) {
|
||||
sends.push(this.sendWithRetry('webhook', anomaly, sessionId, targetUrl));
|
||||
}
|
||||
await Promise.allSettled(sends);
|
||||
}
|
||||
async sendWithRetry(channel, anomaly, sessionId, targetUrl) {
|
||||
const record = {
|
||||
id: crypto.randomUUID(),
|
||||
anomalyId: anomaly.id,
|
||||
channel,
|
||||
status: 'pending',
|
||||
};
|
||||
try {
|
||||
await this.doSend(channel, anomaly, sessionId, targetUrl);
|
||||
record.status = 'success';
|
||||
record.sentAt = Date.now();
|
||||
this.persister?.(record);
|
||||
}
|
||||
catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
// Retry once after 60s
|
||||
setTimeout(async () => {
|
||||
const retryRecord = {
|
||||
id: crypto.randomUUID(),
|
||||
anomalyId: anomaly.id,
|
||||
channel,
|
||||
status: 'pending',
|
||||
};
|
||||
try {
|
||||
await this.doSend(channel, anomaly, sessionId, targetUrl);
|
||||
retryRecord.status = 'success';
|
||||
retryRecord.sentAt = Date.now();
|
||||
this.persister?.(retryRecord);
|
||||
}
|
||||
catch (retryErr) {
|
||||
retryRecord.status = 'failed';
|
||||
retryRecord.error = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
this.persister?.(retryRecord);
|
||||
}
|
||||
}, 60000);
|
||||
record.status = 'failed';
|
||||
record.error = errMsg;
|
||||
this.persister?.(record);
|
||||
}
|
||||
}
|
||||
async doSend(channel, anomaly, sessionId, targetUrl) {
|
||||
if (channel === 'slack' && this.slack) {
|
||||
await this.slack.send(anomaly, sessionId, targetUrl);
|
||||
}
|
||||
else if (channel === 'webhook' && this.webhook) {
|
||||
await this.webhook.send(anomaly);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.NotificationService = NotificationService;
|
||||
57
dist/server/notifications/SlackNotifier.js
vendored
Normal file
57
dist/server/notifications/SlackNotifier.js
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
/**
|
||||
* SlackNotifier — sends anomaly notifications to a Slack webhook.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SlackNotifier = void 0;
|
||||
const SEVERITY_EMOJI = {
|
||||
low: ':blue_circle:',
|
||||
medium: ':yellow_circle:',
|
||||
high: ':red_circle:',
|
||||
critical: ':rotating_light:',
|
||||
};
|
||||
class SlackNotifier {
|
||||
constructor(webhookUrl, frontendBaseUrl = 'http://localhost:5173') {
|
||||
this.webhookUrl = webhookUrl;
|
||||
this.frontendBaseUrl = frontendBaseUrl;
|
||||
}
|
||||
async send(anomaly, sessionId, targetUrl) {
|
||||
const emoji = SEVERITY_EMOJI[anomaly.severity] ?? ':warning:';
|
||||
const payload = {
|
||||
text: '🐛 ABE found a bug!',
|
||||
blocks: [
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*ABE Bug Report*\n` +
|
||||
`*Severity:* ${emoji} ${anomaly.severity.toUpperCase()}\n` +
|
||||
`*Type:* ${anomaly.type}\n` +
|
||||
`*Description:* ${anomaly.description}\n` +
|
||||
`*Session:* ${sessionId}\n` +
|
||||
`*Target:* ${targetUrl}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
type: 'button',
|
||||
text: { type: 'plain_text', text: 'View Report' },
|
||||
url: `${this.frontendBaseUrl}/anomalies/${anomaly.id}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const res = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Slack webhook returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.SlackNotifier = SlackNotifier;
|
||||
25
dist/server/notifications/WebhookNotifier.js
vendored
Normal file
25
dist/server/notifications/WebhookNotifier.js
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
/**
|
||||
* WebhookNotifier — posts full anomaly JSON to a generic webhook URL.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WebhookNotifier = void 0;
|
||||
class WebhookNotifier {
|
||||
constructor(webhookUrl) {
|
||||
this.webhookUrl = webhookUrl;
|
||||
}
|
||||
async send(anomaly) {
|
||||
const res = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-ABE-Event': 'anomaly.detected',
|
||||
},
|
||||
body: JSON.stringify(anomaly),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Webhook returned ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.WebhookNotifier = WebhookNotifier;
|
||||
93
dist/server/routes/anomalies.js
vendored
Normal file
93
dist/server/routes/anomalies.js
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createAnomalyRouter = createAnomalyRouter;
|
||||
const express_1 = require("express");
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
function createAnomalyRouter(store, enrichmentService) {
|
||||
const router = (0, express_1.Router)();
|
||||
// GET /api/anomalies — list all anomalies (optionally filtered)
|
||||
router.get('/', (req, res) => {
|
||||
const sessionId = req.query['sessionId'];
|
||||
const severity = req.query['severity'];
|
||||
const anomalies = store.getAllAnomalies(sessionId, severity);
|
||||
const mapped = anomalies.map((a) => ({
|
||||
id: a.id,
|
||||
sessionId: store.findSessionForAnomaly(a.id),
|
||||
type: a.type,
|
||||
severity: a.severity,
|
||||
description: a.description,
|
||||
timestamp: a.timestamp,
|
||||
screenshotUrl: a.evidence.screenshotPath
|
||||
? `/api/anomalies/${a.id}/screenshot`
|
||||
: undefined,
|
||||
}));
|
||||
res.json(mapped);
|
||||
});
|
||||
// GET /api/anomalies/:anomalyId — full anomaly detail
|
||||
router.get('/:anomalyId', (req, res) => {
|
||||
const anomalyId = req.params['anomalyId'];
|
||||
const anomaly = store.getAnomaly(anomalyId);
|
||||
if (!anomaly) {
|
||||
res.status(404).json({ error: 'Anomaly not found' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
...anomaly,
|
||||
sessionId: store.findSessionForAnomaly(anomaly.id),
|
||||
screenshotUrl: anomaly.evidence.screenshotPath
|
||||
? `/api/anomalies/${anomaly.id}/screenshot`
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
// GET /api/anomalies/:anomalyId/screenshot — serve PNG
|
||||
router.get('/:anomalyId/screenshot', (req, res) => {
|
||||
const anomalyId = req.params['anomalyId'];
|
||||
const filePath = store.screenshotPath(anomalyId);
|
||||
if (!filePath || !fs_1.default.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'Screenshot not found' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
fs_1.default.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
// POST /api/anomalies/:anomalyId/replay — trigger replay
|
||||
router.post('/:anomalyId/replay', async (req, res) => {
|
||||
const anomalyId = req.params['anomalyId'];
|
||||
try {
|
||||
const replayId = await store.replayAnomaly(anomalyId);
|
||||
res.json({ replayId, status: 'running' });
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
res.status(404).json({ error: msg });
|
||||
}
|
||||
});
|
||||
// POST /api/anomalies/:anomalyId/enrich — AI enrichment
|
||||
router.post('/:anomalyId/enrich', async (req, res) => {
|
||||
const anomalyId = req.params['anomalyId'];
|
||||
const anomaly = store.getAnomaly(anomalyId);
|
||||
if (!anomaly) {
|
||||
res.status(404).json({ error: 'Anomaly not found' });
|
||||
return;
|
||||
}
|
||||
if (!enrichmentService?.hasProvider()) {
|
||||
res.status(503).json({ error: 'No AI provider configured (set ABE_AI_PROVIDER)' });
|
||||
return;
|
||||
}
|
||||
const context = {
|
||||
domSnapshot: '',
|
||||
httpLog: anomaly.evidence.httpLog ?? [],
|
||||
consoleErrors: anomaly.evidence.rawErrors ?? [],
|
||||
actionTrace: anomaly.actionTrace,
|
||||
pageTitle: '',
|
||||
url: anomaly.actionTrace[anomaly.actionTrace.length - 1]?.url ?? '',
|
||||
};
|
||||
// Run async — emit WS event when done
|
||||
void enrichmentService.enrich(anomaly, context);
|
||||
res.json({ status: 'enriching', anomalyId });
|
||||
});
|
||||
return router;
|
||||
}
|
||||
48
dist/server/routes/config.js
vendored
Normal file
48
dist/server/routes/config.js
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Config routes — GET /api/config and PATCH /api/config
|
||||
* Manages server-side configuration for notifications and defaults.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getServerConfig = getServerConfig;
|
||||
exports.createConfigRouter = createConfigRouter;
|
||||
const express_1 = require("express");
|
||||
const defaultConfig = {
|
||||
slackWebhookUrl: process.env['ABE_SLACK_WEBHOOK_URL'] ?? null,
|
||||
notifyMinSeverity: process.env['ABE_NOTIFY_MIN_SEVERITY'] ?? 'high',
|
||||
defaultMaxStates: 50,
|
||||
defaultMaxDepth: 5,
|
||||
defaultActionDelayMs: 500,
|
||||
defaultExcludedPaths: [],
|
||||
};
|
||||
let serverConfig = { ...defaultConfig };
|
||||
function getServerConfig() {
|
||||
return { ...serverConfig };
|
||||
}
|
||||
function createConfigRouter() {
|
||||
const router = (0, express_1.Router)();
|
||||
// GET /api/config — returns current config (without API key)
|
||||
router.get('/', (_req, res) => {
|
||||
res.json(serverConfig);
|
||||
});
|
||||
// PATCH /api/config — updates config fields
|
||||
router.patch('/', (req, res) => {
|
||||
const body = req.body;
|
||||
const validKeys = [
|
||||
'slackWebhookUrl',
|
||||
'notifyMinSeverity',
|
||||
'defaultMaxStates',
|
||||
'defaultMaxDepth',
|
||||
'defaultActionDelayMs',
|
||||
'defaultExcludedPaths',
|
||||
];
|
||||
for (const key of validKeys) {
|
||||
if (key in body) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
serverConfig[key] = body[key];
|
||||
}
|
||||
}
|
||||
res.json(serverConfig);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
122
dist/server/routes/schedules.js
vendored
Normal file
122
dist/server/routes/schedules.js
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Schedules routes — CRUD for /api/schedules
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createScheduleRouter = createScheduleRouter;
|
||||
const express_1 = require("express");
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const cron = __importStar(require("node-cron"));
|
||||
const SchedulerService_1 = require("../scheduler/SchedulerService");
|
||||
function createScheduleRouter(scheduleRepo, scheduler) {
|
||||
const router = (0, express_1.Router)();
|
||||
// GET /api/schedules
|
||||
router.get('/', (_req, res) => {
|
||||
const schedules = scheduleRepo.findAll();
|
||||
res.json(schedules);
|
||||
});
|
||||
// POST /api/schedules
|
||||
router.post('/', (req, res) => {
|
||||
const { name, url, config, cronExpression, enabled } = req.body;
|
||||
if (!name || !url || !cronExpression) {
|
||||
res.status(400).json({ error: 'name, url, and cronExpression are required' });
|
||||
return;
|
||||
}
|
||||
if (!cron.validate(cronExpression)) {
|
||||
res.status(400).json({ error: 'Invalid cron expression' });
|
||||
return;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const nextRunAt = SchedulerService_1.SchedulerService.computeNextRunAt(cronExpression);
|
||||
scheduleRepo.create({
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
configJson: JSON.stringify(config ?? {}),
|
||||
cronExpression,
|
||||
enabled: enabled !== false,
|
||||
nextRunAt: nextRunAt ?? undefined,
|
||||
});
|
||||
const record = scheduleRepo.findById(id);
|
||||
if (record.enabled) {
|
||||
scheduler.register(record);
|
||||
}
|
||||
res.status(201).json(record);
|
||||
});
|
||||
// PATCH /api/schedules/:id
|
||||
router.patch('/:id', (req, res) => {
|
||||
const id = String(req.params['id']);
|
||||
const existing = scheduleRepo.findById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Schedule not found' });
|
||||
return;
|
||||
}
|
||||
const { name, url, config, cronExpression, enabled } = req.body;
|
||||
if (cronExpression !== undefined && !cron.validate(cronExpression)) {
|
||||
res.status(400).json({ error: 'Invalid cron expression' });
|
||||
return;
|
||||
}
|
||||
scheduleRepo.update(id, {
|
||||
name,
|
||||
url,
|
||||
configJson: config !== undefined ? JSON.stringify(config) : undefined,
|
||||
cronExpression,
|
||||
enabled,
|
||||
});
|
||||
const updated = scheduleRepo.findById(id);
|
||||
// Re-register/unregister cron job
|
||||
if (updated.enabled) {
|
||||
scheduler.register(updated);
|
||||
}
|
||||
else {
|
||||
scheduler.unregister(id);
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
// DELETE /api/schedules/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const id = String(req.params['id']);
|
||||
const existing = scheduleRepo.findById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Schedule not found' });
|
||||
return;
|
||||
}
|
||||
scheduler.unregister(String(id));
|
||||
scheduleRepo.delete(String(id));
|
||||
res.status(204).send();
|
||||
});
|
||||
return router;
|
||||
}
|
||||
104
dist/server/routes/sessions.js
vendored
Normal file
104
dist/server/routes/sessions.js
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createSessionRouter = createSessionRouter;
|
||||
const express_1 = require("express");
|
||||
const ExplorationConfig_1 = require("../../core/ExplorationConfig");
|
||||
function createSessionRouter(store) {
|
||||
const router = (0, express_1.Router)();
|
||||
// POST /api/sessions — start a new exploration
|
||||
router.post('/', async (req, res) => {
|
||||
const body = req.body;
|
||||
const { url, seed = 42 } = body;
|
||||
if (!url || typeof url !== 'string') {
|
||||
res.status(400).json({ error: 'url is required' });
|
||||
return;
|
||||
}
|
||||
// Enforce concurrent session limit
|
||||
const stats = store.getStats();
|
||||
const limit = store.getMaxConcurrent();
|
||||
if (stats.runningSessions >= limit) {
|
||||
res.status(429).json({
|
||||
error: 'Max concurrent sessions reached',
|
||||
active: stats.runningSessions,
|
||||
limit,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const config = {
|
||||
...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG,
|
||||
...(body.config ?? {}),
|
||||
};
|
||||
// If allowedDomains not specified, derive from the target URL
|
||||
if (config.allowedDomains.length === 0) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
config.allowedDomains = [hostname];
|
||||
}
|
||||
catch {
|
||||
// leave empty
|
||||
}
|
||||
}
|
||||
const record = await store.startSession({
|
||||
url,
|
||||
seed,
|
||||
maxStates: config.maxStates,
|
||||
explorationConfig: config,
|
||||
});
|
||||
res.status(201).json({
|
||||
sessionId: record.sessionId,
|
||||
status: record.status,
|
||||
startedAt: record.startedAt,
|
||||
});
|
||||
});
|
||||
// GET /api/sessions — list all sessions
|
||||
router.get('/', (_req, res) => {
|
||||
const sessions = store.getAllSessions().map((s) => ({
|
||||
sessionId: s.sessionId,
|
||||
url: s.url,
|
||||
status: s.status,
|
||||
startedAt: s.startedAt,
|
||||
anomaliesFound: s.anomaliesFound,
|
||||
statesVisited: s.statesVisited,
|
||||
}));
|
||||
res.json(sessions);
|
||||
});
|
||||
// GET /api/sessions/:sessionId — session detail
|
||||
router.get('/:sessionId', (req, res) => {
|
||||
const record = store.getSession(req.params['sessionId']);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
sessionId: record.sessionId,
|
||||
url: record.url,
|
||||
status: record.status,
|
||||
startedAt: record.startedAt,
|
||||
finishedAt: record.finishedAt,
|
||||
statesVisited: record.statesVisited,
|
||||
anomaliesFound: record.anomaliesFound,
|
||||
seed: record.seed,
|
||||
});
|
||||
});
|
||||
// DELETE /api/sessions/:sessionId — stop an active session
|
||||
router.delete('/:sessionId', (req, res) => {
|
||||
const stopped = store.stopSession(req.params['sessionId']);
|
||||
if (!stopped) {
|
||||
res.status(404).json({ error: 'Session not found or not running' });
|
||||
return;
|
||||
}
|
||||
res.json({ stopped: true });
|
||||
});
|
||||
// GET /api/sessions/:sessionId/performance — performance metrics for session
|
||||
router.get('/:sessionId/performance', (req, res) => {
|
||||
const sessionId = req.params['sessionId'];
|
||||
const record = store.getSession(sessionId);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
const metrics = store.getPerformanceMetrics(sessionId);
|
||||
res.json(metrics);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
52
dist/server/routes/visual.js
vendored
Normal file
52
dist/server/routes/visual.js
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Visual regression routes — /api/visual
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createVisualRouter = createVisualRouter;
|
||||
const express_1 = require("express");
|
||||
function createVisualRouter(repo) {
|
||||
const router = (0, express_1.Router)();
|
||||
// GET /api/visual/comparisons
|
||||
router.get('/comparisons', (req, res) => {
|
||||
const sessionId = req.query['sessionId'];
|
||||
const status = req.query['status'];
|
||||
const comparisons = repo.findComparisons({ sessionId, status });
|
||||
res.json(comparisons);
|
||||
});
|
||||
// POST /api/visual/baselines/:comparisonId/approve
|
||||
router.post('/baselines/:comparisonId/approve', (req, res) => {
|
||||
const comparisonId = String(req.params['comparisonId']);
|
||||
const comparison = repo.findComparisonById(comparisonId);
|
||||
if (!comparison) {
|
||||
res.status(404).json({ error: 'Comparison not found' });
|
||||
return;
|
||||
}
|
||||
const baselineId = repo.promoteToBaseline(comparisonId);
|
||||
res.json({ baselineId, status: 'approved' });
|
||||
});
|
||||
// POST /api/visual/baselines/:comparisonId/reject
|
||||
router.post('/baselines/:comparisonId/reject', (req, res) => {
|
||||
const comparisonId = String(req.params['comparisonId']);
|
||||
const comparison = repo.findComparisonById(comparisonId);
|
||||
if (!comparison) {
|
||||
res.status(404).json({ error: 'Comparison not found' });
|
||||
return;
|
||||
}
|
||||
repo.updateComparisonStatus(comparisonId, 'failed');
|
||||
res.json({ status: 'rejected' });
|
||||
});
|
||||
// POST /api/visual/baselines/approve-all
|
||||
router.post('/baselines/approve-all', (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
const pending = repo.findComparisons({ sessionId, status: 'new_state' });
|
||||
const approved = [];
|
||||
for (const comp of pending) {
|
||||
const id = repo.promoteToBaseline(comp.id);
|
||||
if (id)
|
||||
approved.push(id);
|
||||
}
|
||||
res.json({ approved: approved.length });
|
||||
});
|
||||
return router;
|
||||
}
|
||||
140
dist/server/scheduler/SchedulerService.js
vendored
Normal file
140
dist/server/scheduler/SchedulerService.js
vendored
Normal file
@@ -0,0 +1,140 @@
|
||||
"use strict";
|
||||
/**
|
||||
* SchedulerService — manages cron-based scheduled explorations.
|
||||
* Loads schedules from DB on startup, registers cron jobs, and triggers sessions.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SchedulerService = void 0;
|
||||
const cron = __importStar(require("node-cron"));
|
||||
const logger_1 = require("../logger");
|
||||
class SchedulerService {
|
||||
constructor(scheduleRepo, sessionStore) {
|
||||
this.scheduleRepo = scheduleRepo;
|
||||
this.sessionStore = sessionStore;
|
||||
this.jobs = new Map();
|
||||
}
|
||||
/** Load all enabled schedules and start cron jobs. */
|
||||
start() {
|
||||
const schedules = this.scheduleRepo.findAll(true);
|
||||
for (const schedule of schedules) {
|
||||
this.register(schedule);
|
||||
}
|
||||
logger_1.log.info({ count: schedules.length }, 'SchedulerService started');
|
||||
}
|
||||
/** Stop all cron jobs. */
|
||||
stop() {
|
||||
for (const [id, task] of this.jobs) {
|
||||
task.stop();
|
||||
logger_1.log.info({ scheduleId: id }, 'Cron job stopped');
|
||||
}
|
||||
this.jobs.clear();
|
||||
}
|
||||
/** Register (or re-register) a cron job for a schedule. */
|
||||
register(schedule) {
|
||||
this.unregister(schedule.id);
|
||||
if (!schedule.enabled)
|
||||
return;
|
||||
if (!cron.validate(schedule.cronExpression)) {
|
||||
logger_1.log.warn({ scheduleId: schedule.id, cron: schedule.cronExpression }, 'Invalid cron expression, skipping');
|
||||
return;
|
||||
}
|
||||
const task = cron.schedule(schedule.cronExpression, () => {
|
||||
void this.fire(schedule.id);
|
||||
});
|
||||
this.jobs.set(schedule.id, task);
|
||||
logger_1.log.info({ scheduleId: schedule.id, cron: schedule.cronExpression }, 'Cron job registered');
|
||||
}
|
||||
/** Unregister a cron job. */
|
||||
unregister(scheduleId) {
|
||||
const existing = this.jobs.get(scheduleId);
|
||||
if (existing) {
|
||||
existing.stop();
|
||||
this.jobs.delete(scheduleId);
|
||||
}
|
||||
}
|
||||
/** Fire a scheduled run. */
|
||||
async fire(scheduleId) {
|
||||
const schedule = this.scheduleRepo.findById(scheduleId);
|
||||
if (!schedule || !schedule.enabled)
|
||||
return;
|
||||
// Check if a session from this schedule is still running
|
||||
const running = this.sessionStore.getAllSessions().filter((s) => s.status === 'running');
|
||||
if (running.length > 0) {
|
||||
// Check if any running session was created from this schedule
|
||||
const scheduleConfig = JSON.parse(schedule.configJson);
|
||||
const alreadyRunning = running.some((s) => {
|
||||
try {
|
||||
const cfg = JSON.parse(s.config_json ?? '{}');
|
||||
return cfg.scheduleId === scheduleId;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (alreadyRunning) {
|
||||
logger_1.log.warn({ scheduleId }, 'Previous session still running, skipping scheduled tick');
|
||||
return;
|
||||
}
|
||||
void scheduleConfig; // suppress unused warning
|
||||
}
|
||||
logger_1.log.info({ scheduleId, url: schedule.url }, 'Firing scheduled exploration');
|
||||
const now = Date.now();
|
||||
this.scheduleRepo.update(scheduleId, { lastRunAt: now });
|
||||
try {
|
||||
const config = JSON.parse(schedule.configJson);
|
||||
// Inject scheduleId into config for tracking
|
||||
config.scheduleId = scheduleId;
|
||||
await this.sessionStore.startSession({
|
||||
url: schedule.url,
|
||||
seed: Math.floor(Math.random() * 0x7fffffff),
|
||||
maxStates: config.maxStates ?? 50,
|
||||
explorationConfig: config,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.log.error({ scheduleId, err: err instanceof Error ? err.message : String(err) }, 'Scheduled session failed to start');
|
||||
}
|
||||
}
|
||||
/** Compute approximate next run time for a cron expression. */
|
||||
static computeNextRunAt(cronExpression) {
|
||||
if (!cron.validate(cronExpression))
|
||||
return null;
|
||||
// Simple heuristic: use current time + 60s as a placeholder
|
||||
// A proper implementation would parse the cron and compute the next trigger
|
||||
return Date.now() + 60000;
|
||||
}
|
||||
}
|
||||
exports.SchedulerService = SchedulerService;
|
||||
Reference in New Issue
Block a user