/** * 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 [];}
/**
* 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 [];
}