import { Injectable, Logger } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import type { RejectionContext, RejectionResult, EscalationConfig, EscalationRule, FailureSummary, } from "./interfaces"; @Injectable() export class RejectionHandlerService { private readonly logger = new Logger(RejectionHandlerService.name); constructor(private readonly prisma: PrismaService) {} /** * Handle a rejected task */ async handleRejection(context: RejectionContext): Promise { this.logger.warn( `Handling rejection for task ${context.taskId} after ${String(context.attemptCount)} attempts` ); // Log rejection to database await this.logRejection(context); // Get escalation config const config = this.getDefaultEscalationConfig(); // Determine escalation actions const escalationRules = this.determineEscalation(context, config); // Execute escalation const notificationsSent: string[] = []; if (escalationRules.length > 0) { await this.executeEscalation(context, escalationRules); // Collect notification targets escalationRules.forEach((rule) => { if (rule.action === "notify" && rule.target) { notificationsSent.push(rule.target); } }); } // Determine task state based on escalation const taskState = this.determineTaskState(escalationRules); // Check if manual review is required const manualReviewRequired = context.attemptCount >= 3 || escalationRules.some((r) => r.action === "block" || r.priority === "critical"); if (manualReviewRequired) { await this.markForManualReview( context.taskId, `Max attempts (${String(context.attemptCount)}) exceeded or critical failure detected` ); } return { handled: true, escalated: escalationRules.length > 0, notificationsSent, taskState, manualReviewRequired, }; } /** * Log rejection to database */ async logRejection(context: RejectionContext): Promise { await this.prisma.taskRejection.create({ data: { taskId: context.taskId, workspaceId: context.workspaceId, agentId: context.agentId, attemptCount: context.attemptCount, failures: context.failures as unknown as Prisma.InputJsonValue, originalTask: context.originalTask, startedAt: context.startedAt, rejectedAt: context.rejectedAt, escalated: false, manualReview: false, }, }); this.logger.log(`Logged rejection for task ${context.taskId} to database`); } /** * Determine escalation actions */ determineEscalation(context: RejectionContext, config: EscalationConfig): EscalationRule[] { const applicableRules: EscalationRule[] = []; // Check each rule condition for (const rule of config.rules) { if (this.checkRuleCondition(context, rule, config)) { applicableRules.push(rule); } } return applicableRules; } /** * Check if a rule condition is met */ private checkRuleCondition( context: RejectionContext, rule: EscalationRule, config: EscalationConfig ): boolean { switch (rule.condition) { case "max-attempts": return context.attemptCount >= 3; case "time-exceeded": { const durationMinutes = (context.rejectedAt.getTime() - context.startedAt.getTime()) / (1000 * 60); return durationMinutes > config.maxWaitTime; } case "critical-failure": return context.failures.some( (f) => f.failureType.includes("critical") || f.failureType.includes("security") || f.failureType.includes("vulnerability") ); default: return false; } } /** * Execute escalation rules */ async executeEscalation(context: RejectionContext, rules: EscalationRule[]): Promise { for (const rule of rules) { this.logger.warn( `Executing escalation: ${rule.action} for ${rule.condition} (priority: ${rule.priority})` ); switch (rule.action) { case "notify": if (rule.target) { this.sendNotification(context, rule.target, rule.priority); } break; case "block": await this.markForManualReview(context.taskId, `Task blocked due to ${rule.condition}`); break; case "reassign": this.logger.warn(`Task ${context.taskId} marked for reassignment`); // Future: implement reassignment logic break; case "cancel": this.logger.warn(`Task ${context.taskId} marked for cancellation`); // Future: implement cancellation logic break; } } } /** * Send rejection notification */ sendNotification(context: RejectionContext, target: string, priority: string): void { const report = this.generateRejectionReport(context); this.logger.warn( `[${priority.toUpperCase()}] Sending rejection notification to ${target} for task ${context.taskId}` ); this.logger.debug(`Notification content:\n${report}`); // Future: integrate with notification service (email, Slack, etc.) // For now, just log the notification } /** * Mark task as requiring manual review */ async markForManualReview(taskId: string, reason: string): Promise { // Update the most recent rejection record for this task const rejections = await this.prisma.taskRejection.findMany({ where: { taskId }, orderBy: { rejectedAt: "desc" }, take: 1, }); if (rejections.length > 0 && rejections[0]) { await this.prisma.taskRejection.update({ where: { id: rejections[0].id }, data: { manualReview: true, escalated: true, }, }); this.logger.warn(`Task ${taskId} marked for manual review: ${reason}`); } } /** * Get rejection history for a task */ async getRejectionHistory(taskId: string): Promise { const rejections = await this.prisma.taskRejection.findMany({ where: { taskId }, orderBy: { rejectedAt: "desc" }, }); return rejections.map((r) => ({ taskId: r.taskId, workspaceId: r.workspaceId, agentId: r.agentId, attemptCount: r.attemptCount, failures: r.failures as unknown as FailureSummary[], originalTask: r.originalTask, startedAt: r.startedAt, rejectedAt: r.rejectedAt, })); } /** * Generate rejection report */ generateRejectionReport(context: RejectionContext): string { const duration = this.formatDuration(context.startedAt, context.rejectedAt); const failureList = context.failures .map((f) => `- **${f.gateName}**: ${f.message} (${String(f.attempts)} attempts)`) .join("\n"); const recommendations = this.generateRecommendations(context.failures); return ` ## Task Rejection Report **Task ID:** ${context.taskId} **Workspace:** ${context.workspaceId} **Agent:** ${context.agentId} **Attempts:** ${String(context.attemptCount)} **Duration:** ${duration} **Started:** ${context.startedAt.toISOString()} **Rejected:** ${context.rejectedAt.toISOString()} ### Original Task ${context.originalTask} ### Failures ${failureList} ### Required Actions - Manual code review required - Fix the following issues before reassigning - Review agent output and error logs ### Recommendations ${recommendations} --- *This report was generated automatically by the Quality Rails rejection handler.* `; } /** * Get default escalation config */ getDefaultEscalationConfig(): EscalationConfig { return { rules: [ { condition: "max-attempts", action: "notify", target: "admin@mosaicstack.dev", priority: "high", }, { condition: "max-attempts", action: "block", priority: "high", }, { condition: "critical-failure", action: "notify", target: "security@mosaicstack.dev", priority: "critical", }, { condition: "critical-failure", action: "block", priority: "critical", }, { condition: "time-exceeded", action: "notify", target: "admin@mosaicstack.dev", priority: "medium", }, ], notifyOnRejection: true, autoReassign: false, maxWaitTime: 120, // 2 hours }; } /** * Determine task state based on escalation rules */ private determineTaskState(rules: EscalationRule[]): "blocked" | "reassigned" | "cancelled" { // Check for explicit state changes if (rules.some((r) => r.action === "cancel")) { return "cancelled"; } if (rules.some((r) => r.action === "reassign")) { return "reassigned"; } if (rules.some((r) => r.action === "block")) { return "blocked"; } // Default to blocked if any escalation occurred return "blocked"; } /** * Format duration between two dates */ private formatDuration(start: Date, end: Date): string { const durationMs = end.getTime() - start.getTime(); const hours = Math.floor(durationMs / (1000 * 60 * 60)); const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); if (hours > 0) { return `${String(hours)}h ${String(minutes)}m`; } return `${String(minutes)}m`; } /** * Generate recommendations based on failure types */ private generateRecommendations(failures: FailureSummary[]): string { const recommendations: string[] = []; failures.forEach((failure) => { switch (failure.gateName) { case "type-check": recommendations.push( "- Review TypeScript errors and ensure all types are properly defined" ); recommendations.push( "- Check for missing type definitions or incorrect type annotations" ); break; case "test-gate": recommendations.push( "- Review failing tests and update implementation to meet test expectations" ); recommendations.push("- Verify test mocks and fixtures are correctly configured"); break; case "lint-gate": recommendations.push("- Run ESLint and fix all reported issues"); recommendations.push( "- Consider adding ESLint disable comments only for false positives" ); break; case "security-scan": recommendations.push( "- **CRITICAL**: Review and fix security vulnerabilities immediately" ); recommendations.push("- Do not proceed until security issues are resolved"); break; case "coverage-gate": recommendations.push( "- Add additional tests to increase coverage above minimum threshold" ); recommendations.push("- Focus on untested edge cases and error paths"); break; default: recommendations.push(`- Review ${failure.gateName} failures and address root causes`); } }); // Deduplicate recommendations const uniqueRecommendations = [...new Set(recommendations)]; return uniqueRecommendations.length > 0 ? uniqueRecommendations.join("\n") : "- Review error logs and agent output for additional context"; } }