agent-ecosystem/src/main/services/error/ErrorTriggerTester.ts

329 lines
9.4 KiB
TypeScript

/**
* ErrorTriggerTester service - Testing functionality for trigger preview.
*
* Provides utilities for:
* - Testing trigger configurations against historical session data
* - Running single trigger detection for preview functionality
*/
import { type ParsedMessage } from '@main/types';
import { parseJsonlFile } from '@main/utils/jsonl';
import { createLogger } from '@shared/utils/logger';
import * as path from 'path';
import {
buildToolResultMap,
buildToolUseMap,
type ToolResultInfo,
type ToolUseInfo,
} from '../analysis/ToolResultExtractor';
import { ProjectScanner } from '../discovery/ProjectScanner';
import { type NotificationTrigger } from '../infrastructure/ConfigManager';
const logger = createLogger('Service:ErrorTriggerTester');
import { type DetectedError } from './ErrorMessageBuilder';
import {
checkTokenThresholdTrigger,
checkToolResultTrigger,
checkToolUseTrigger,
matchesRepositoryScope,
preResolveRepositoryIds,
} from './ErrorTriggerChecker';
// =============================================================================
// Trigger Testing (Preview Feature)
// =============================================================================
/**
* Safety limits to prevent resource exhaustion from faulty triggers.
*
* Strategy: Stop as soon as we find enough results, not after scanning N sessions.
* This allows finding rare patterns (like .env) while still being fast for common patterns.
*/
const TEST_LIMITS = {
/** Maximum number of errors to return (primary stop condition) */
MAX_ERRORS: 50,
/** Maximum totalCount to track (prevents indefinite counting) */
MAX_TOTAL_COUNT: 10_000,
/** Maximum time in ms before aborting (30 seconds) - main safety limit */
TIMEOUT_MS: 30_000,
} as const;
/**
* State object used during trigger testing to track progress and limits.
*/
interface TestState {
errors: DetectedError[];
totalCount: number;
sessionsScanned: number;
truncated: boolean;
startTime: number;
effectiveLimit: number;
}
/**
* Checks if the test should stop due to hitting safety limits.
* Returns a reason string if should stop, null if should continue.
*
* Stop conditions (in order of priority):
* 1. Found enough errors (effectiveLimit) - success, no warning
* 2. Timeout (30s) - safety limit
* 3. Total count limit (10k) - prevent counting forever
*/
function shouldStopTest(state: TestState): string | null {
// Primary stop condition: found enough errors
if (state.errors.length >= state.effectiveLimit) {
return null; // Stop but don't log - we have enough errors (success case)
}
// Safety limits
if (Date.now() - state.startTime > TEST_LIMITS.TIMEOUT_MS) {
return 'Trigger test timed out after 30 seconds';
}
if (state.totalCount >= TEST_LIMITS.MAX_TOTAL_COUNT) {
return 'Trigger test stopped after reaching count limit';
}
return null;
}
/**
* Tests a trigger configuration against historical session data.
* Returns a list of errors that would have been detected.
*
* Strategy: Scan sessions until we find enough results or hit safety limits.
* This allows finding rare patterns while staying fast for common patterns.
*
* Stop conditions:
* - Found enough errors (limit) - primary success condition
* - Timeout (30s) - safety limit
* - Total count reached (10k) - prevents infinite counting
*
* @param trigger - The trigger configuration to test
* @param limit - Maximum number of results to return (default 50, capped at MAX_ERRORS)
*/
export async function testTrigger(
trigger: NotificationTrigger,
limit: number = TEST_LIMITS.MAX_ERRORS
): Promise<{
totalCount: number;
errors: DetectedError[];
/** True if results were truncated due to safety limits */
truncated?: boolean;
}> {
const projectScanner = new ProjectScanner();
const state: TestState = {
errors: [],
totalCount: 0,
sessionsScanned: 0,
truncated: false,
startTime: Date.now(),
effectiveLimit: Math.min(limit, TEST_LIMITS.MAX_ERRORS),
};
try {
// Get list of all projects
const projects = await projectScanner.scan();
// Process each project to find session files
for (const project of projects) {
// Check safety limits before processing project
const stopReason = shouldStopTest(state);
if (stopReason) {
logger.warn(stopReason);
state.truncated = true;
break;
}
// Early exit if we have enough errors (no truncation warning needed)
if (state.errors.length >= state.effectiveLimit) break;
const sessionFiles = await projectScanner.listSessionFiles(project.id);
// Pre-resolve repository ID for this project.
await preResolveRepositoryIds([{ projectId: project.id, cwdHint: project.path }]);
// Process each session file (most recent first)
const shouldBreakOuter = await processSessionFiles(
sessionFiles,
trigger,
project.id,
state,
parseJsonlFile
);
if (shouldBreakOuter) break;
}
return { totalCount: state.totalCount, errors: state.errors, truncated: state.truncated };
} catch (error) {
logger.error('Error testing trigger:', error);
return { totalCount: 0, errors: [] };
}
}
/**
* Processes session files for a single project.
* Returns true if outer loop should break, false otherwise.
*/
async function processSessionFiles(
sessionFiles: string[],
trigger: NotificationTrigger,
projectId: string,
state: TestState,
parseFile: (path: string) => Promise<ParsedMessage[]>
): Promise<boolean> {
for (const filePath of sessionFiles) {
// Check safety limits
const stopReason = shouldStopTest(state);
if (stopReason) {
logger.warn(stopReason);
state.truncated = true;
return true; // Break outer loop
}
// Early exit if we have enough errors
if (state.errors.length >= state.effectiveLimit) return false;
try {
state.sessionsScanned++;
// Parse session file
const messages = await parseFile(filePath);
// Extract sessionId from file path
const filename = path.basename(filePath);
const sessionId = filename.replace(/\.jsonl$/, '');
// Test the trigger against each message
const sessionErrors = detectErrorsWithTrigger(
messages,
trigger,
sessionId,
projectId,
filePath
);
// Update totalCount but cap it
const newTotal = state.totalCount + sessionErrors.length;
if (newTotal >= TEST_LIMITS.MAX_TOTAL_COUNT) {
state.totalCount = TEST_LIMITS.MAX_TOTAL_COUNT;
state.truncated = true;
} else {
state.totalCount = newTotal;
}
// Add errors up to limit
for (const error of sessionErrors) {
if (state.errors.length >= state.effectiveLimit) break;
state.errors.push(error);
}
} catch (error) {
// Skip files that can't be parsed
logger.error(`Error parsing session file ${filePath}:`, error);
continue;
}
}
return false; // Don't break outer loop
}
/**
* Detects errors from messages using a single trigger.
* Used by testTrigger for preview functionality.
*/
function detectErrorsWithTrigger(
messages: ParsedMessage[],
trigger: NotificationTrigger,
sessionId: string,
projectId: string,
filePath: string
): DetectedError[] {
const errors: DetectedError[] = [];
// Build tool_use map for linking results to calls
const toolUseMap = buildToolUseMap(messages);
// Build tool_result map for estimating output tokens
const toolResultMap = buildToolResultMap(messages);
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const lineNumber = i + 1; // 1-based line numbers for JSONL
const triggerErrors = checkTrigger(
message,
trigger,
toolUseMap,
toolResultMap,
sessionId,
projectId,
filePath,
lineNumber
);
// Add all detected errors (can be multiple for token_threshold mode)
errors.push(...triggerErrors);
}
return errors;
}
/**
* Checks if a message matches a specific trigger.
* Internal helper for detectErrorsWithTrigger.
*/
function checkTrigger(
message: ParsedMessage,
trigger: NotificationTrigger,
toolUseMap: Map<string, ToolUseInfo>,
toolResultMap: Map<string, ToolResultInfo>,
sessionId: string,
projectId: string,
filePath: string,
lineNumber: number
): DetectedError[] {
// Check repository scope first - if repositoryIds is set, only trigger for matching repositories
if (!matchesRepositoryScope(projectId, trigger.repositoryIds)) {
return [];
}
// Use the mode directly (mode is now required in NotificationTrigger)
const effectiveMode = trigger.mode;
// Handle token_threshold mode - check each tool_use individually
if (effectiveMode === 'token_threshold') {
return checkTokenThresholdTrigger(
message,
trigger,
toolResultMap,
sessionId,
projectId,
filePath,
lineNumber
);
}
// Handle tool_result triggers
if (trigger.contentType === 'tool_result') {
const error = checkToolResultTrigger(
message,
trigger,
toolUseMap,
sessionId,
projectId,
filePath,
lineNumber
);
return error ? [error] : [];
}
// Handle tool_use triggers (for future expansion)
if (trigger.contentType === 'tool_use') {
const error = checkToolUseTrigger(message, trigger, sessionId, projectId, filePath, lineNumber);
return error ? [error] : [];
}
return [];
}