aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
530 lines (462 loc) • 16.2 kB
JavaScript
/**
* Human Review Orchestrator (Layer 3 Enhancement)
*
* Orchestrates the complete 3-layer quality gate flow with:
* - Layer 1+2 pass detection
* - Layer 1+2 fail blocking
* - Human review request system
* - Focus area recommendations
* - Notification management
*
* @module core/quality-gates/human-review-orchestrator
* @version 1.0.0
* @story 3.5 - Human Review Orchestration (Layer 3)
*/
const fs = require('fs').promises;
const path = require('path');
const { FocusAreaRecommender } = require('./focus-area-recommender');
const { NotificationManager } = require('./notification-manager');
/**
* Human Review Orchestrator
* Implements the 3-layer orchestration flow
*/
class HumanReviewOrchestrator {
/**
* Create a new orchestrator
* @param {Object} config - Orchestrator configuration
*/
constructor(config = {}) {
this.config = config;
this.focusRecommender = new FocusAreaRecommender(config.focusAreas || {});
this.notificationManager = new NotificationManager(config.notifications || {});
this.statusPath = config.statusPath || '.aios/qa-status.json';
this.reviewRequestsPath = config.reviewRequestsPath || '.aios/human-review-requests';
// Status queue for serializing concurrent writes to status file
this.statusQueue = Promise.resolve();
}
/**
* Orchestrate the full review flow
* @param {Object} prContext - PR/change context
* @param {Object} layer1Result - Layer 1 results
* @param {Object} layer2Result - Layer 2 results
* @returns {Promise<Object>} Orchestration result
*/
async orchestrateReview(prContext, layer1Result, layer2Result) {
const startTime = Date.now();
// Step 1: Check Layer 1 pass/fail
const layer1Check = this.checkLayerPassed(layer1Result, 'Layer 1');
if (!layer1Check.pass) {
return this.block(layer1Check, 'layer1', startTime);
}
// Step 2: Check Layer 2 pass/fail
const layer2Check = this.checkLayerPassed(layer2Result, 'Layer 2');
if (!layer2Check.pass) {
return this.block(layer2Check, 'layer2', startTime);
}
// Step 3: Both layers passed - generate human review request
const reviewRequest = await this.generateHumanReviewRequest(
prContext,
layer1Result,
layer2Result,
);
// Step 4: Send notification to human reviewer
await this.notifyReviewer(reviewRequest);
// Step 5: Save the review request
await this.saveReviewRequest(reviewRequest);
return {
pass: true,
status: 'pending_human_review',
duration: Date.now() - startTime,
message: 'Layers 1+2 passed. Human review requested.',
reviewRequest,
layers: {
layer1: layer1Check,
layer2: layer2Check,
},
};
}
/**
* Check if a layer passed
* @param {Object} layerResult - Layer result object
* @param {string} layerName - Layer name for messaging
* @returns {Object} Check result
*/
checkLayerPassed(layerResult, layerName) {
if (!layerResult) {
return {
pass: false,
layer: layerName,
reason: `${layerName} not executed`,
issues: [],
};
}
const pass = layerResult.pass === true;
const issues = this.extractIssues(layerResult);
return {
pass,
layer: layerName,
duration: layerResult.duration || 0,
checksRun: layerResult.checks?.total || 0,
checksPassed: layerResult.checks?.passed || 0,
checksFailed: layerResult.checks?.failed || 0,
reason: pass ? `${layerName} passed` : `${layerName} failed`,
issues,
};
}
/**
* Extract issues from layer result
* @param {Object} layerResult - Layer result
* @returns {Array} Extracted issues
*/
extractIssues(layerResult) {
const issues = [];
if (!layerResult.results) return issues;
layerResult.results.forEach((result) => {
if (!result.pass) {
issues.push({
check: result.check,
message: result.message,
severity: this.determineSeverity(result),
details: result.error || result.details || null,
});
}
});
return issues;
}
/**
* Determine severity from result
* @param {Object} result - Check result
* @returns {string} Severity level
*/
determineSeverity(result) {
if (result.check === 'lint') return 'HIGH';
if (result.check === 'test') return 'CRITICAL';
if (result.check === 'typecheck') return 'HIGH';
if (result.issues?.critical > 0) return 'CRITICAL';
if (result.issues?.high > 0) return 'HIGH';
return 'MEDIUM';
}
/**
* Block the review flow due to layer failure
* @param {Object} layerCheck - Failed layer check
* @param {string} stoppedAt - Layer that caused the block
* @param {number} startTime - Orchestration start time
* @returns {Object} Block result
*/
block(layerCheck, stoppedAt, startTime) {
const blockMessages = {
layer1: 'Fix linting, tests, and type errors before human review',
layer2: 'Fix CodeRabbit and Quinn issues before human review',
};
return {
pass: false,
status: 'blocked',
stoppedAt,
duration: Date.now() - startTime,
message: blockMessages[stoppedAt] || 'Fix issues before proceeding',
reason: layerCheck.reason,
issues: layerCheck.issues,
fixFirst: this.generateFixRecommendations(layerCheck),
};
}
/**
* Generate fix recommendations for blocked issues
* @param {Object} layerCheck - Layer check result
* @returns {Array} Fix recommendations
*/
generateFixRecommendations(layerCheck) {
const recommendations = [];
layerCheck.issues.forEach((issue) => {
const rec = {
issue: issue.message,
severity: issue.severity,
suggestion: null,
};
switch (issue.check) {
case 'lint':
rec.suggestion = 'Run `npm run lint:fix` to auto-fix linting issues';
break;
case 'test':
rec.suggestion = 'Run `npm test` and fix failing tests';
break;
case 'typecheck':
rec.suggestion = 'Run `npm run typecheck` and resolve type errors';
break;
case 'coderabbit':
rec.suggestion = 'Review CodeRabbit feedback and address CRITICAL/HIGH issues';
break;
case 'quinn':
rec.suggestion = 'Review Quinn suggestions and address blocking items';
break;
default:
rec.suggestion = `Address ${issue.check} issues before proceeding`;
}
recommendations.push(rec);
});
return recommendations;
}
/**
* Generate human review request with focus areas
* @param {Object} prContext - PR context
* @param {Object} layer1Result - Layer 1 result
* @param {Object} layer2Result - Layer 2 result
* @returns {Promise<Object>} Review request
*/
async generateHumanReviewRequest(prContext, layer1Result, layer2Result) {
// Generate focus area recommendations
const focusAreas = await this.focusRecommender.recommend({
prContext,
layer1Result,
layer2Result,
});
// Generate summary from automated reviews
const automatedSummary = this.generateAutomatedSummary(layer1Result, layer2Result);
return {
id: this.generateRequestId(),
createdAt: new Date().toISOString(),
prContext,
focusAreas,
automatedSummary,
skipAreas: ['syntax', 'formatting', 'simple-logic'],
estimatedTime: this.estimateReviewTime(focusAreas),
reviewer: await this.assignReviewer(prContext),
status: 'pending',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours
};
}
/**
* Generate automated review summary
* @param {Object} layer1Result - Layer 1 results
* @param {Object} layer2Result - Layer 2 results
* @returns {Object} Summary object
*/
generateAutomatedSummary(layer1Result, layer2Result) {
const summary = {
layer1: {
status: layer1Result?.pass ? 'passed' : 'failed',
checks: [],
},
layer2: {
status: layer2Result?.pass ? 'passed' : 'failed',
coderabbit: null,
quinn: null,
},
};
// Layer 1 summary
if (layer1Result?.results) {
layer1Result.results.forEach((r) => {
summary.layer1.checks.push({
check: r.check,
status: r.pass ? 'passed' : (r.skipped ? 'skipped' : 'failed'),
message: r.message,
});
});
}
// Layer 2 CodeRabbit summary
const coderabbitResult = layer2Result?.results?.find((r) => r.check === 'coderabbit');
if (coderabbitResult) {
summary.layer2.coderabbit = {
status: coderabbitResult.pass ? 'passed' : (coderabbitResult.skipped ? 'skipped' : 'issues_found'),
issues: coderabbitResult.issues || { critical: 0, high: 0, medium: 0, low: 0 },
details: coderabbitResult.details?.slice(0, 5) || [], // Top 5 issues
};
}
// Layer 2 Quinn summary
const quinnResult = layer2Result?.results?.find((r) => r.check === 'quinn');
if (quinnResult) {
summary.layer2.quinn = {
status: quinnResult.pass ? 'passed' : (quinnResult.skipped ? 'skipped' : 'issues_found'),
suggestions: quinnResult.suggestions || 0,
blocking: quinnResult.blocking || 0,
details: quinnResult.details?.slice(0, 5) || [], // Top 5 suggestions
};
}
return summary;
}
/**
* Estimate review time based on focus areas
* @param {Object} focusAreas - Focus area recommendations
* @returns {number} Estimated minutes
*/
estimateReviewTime(focusAreas) {
const baseTime = 10; // Base 10 minutes
const perAreaTime = 5; // 5 minutes per focus area
const areaCount = (focusAreas.primary?.length || 0) +
(focusAreas.secondary?.length || 0) * 0.5;
return Math.round(baseTime + (areaCount * perAreaTime));
}
/**
* Assign a reviewer based on context
* @param {Object} prContext - PR context
* @returns {Promise<string>} Assigned reviewer
*/
async assignReviewer(_prContext) {
// Use round-robin or auto-assignment
const reviewers = ['@architect', '@tech-lead', '@senior-dev'];
try {
let status = {};
try {
status = JSON.parse(await fs.readFile(this.statusPath, 'utf8'));
} catch {
// No status file
}
const lastIndex = status.lastReviewerIndex || 0;
const nextIndex = (lastIndex + 1) % reviewers.length;
return reviewers[nextIndex];
} catch {
return reviewers[0];
}
}
/**
* Generate unique request ID
* @returns {string} Request ID
*/
generateRequestId() {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `hr-${timestamp}-${random}`;
}
/**
* Validate request ID to prevent path traversal attacks
* @param {string} id - Request ID to validate
* @returns {string} Validated ID
* @throws {Error} If ID contains invalid characters
*/
validateRequestId(id) {
if (!id || typeof id !== 'string') {
throw new Error('Request ID is required and must be a string');
}
// Only allow alphanumeric, hyphens, underscores, and dots
const validIdPattern = /^[A-Za-z0-9_.-]+$/;
if (!validIdPattern.test(id)) {
throw new Error('Invalid request ID: contains disallowed characters');
}
// Ensure the ID doesn't resolve outside the intended directory
const sanitizedId = path.basename(id);
if (sanitizedId !== id) {
throw new Error('Invalid request ID: path traversal detected');
}
return id;
}
/**
* Notify the assigned reviewer
* @param {Object} reviewRequest - Review request object
* @returns {Promise<Object>} Notification result
*/
async notifyReviewer(reviewRequest) {
return await this.notificationManager.sendReviewRequest(reviewRequest);
}
/**
* Save review request to file system
* @param {Object} reviewRequest - Review request
* @returns {Promise<void>}
*/
async saveReviewRequest(reviewRequest) {
// Validate ID to prevent path traversal
const validatedId = this.validateRequestId(reviewRequest.id);
const requestPath = path.join(
this.reviewRequestsPath,
`${validatedId}.json`,
);
// Additional containment check
const resolvedPath = path.resolve(requestPath);
const resolvedBase = path.resolve(this.reviewRequestsPath);
if (!resolvedPath.startsWith(resolvedBase + path.sep)) {
throw new Error('Path traversal attempt detected');
}
await fs.mkdir(this.reviewRequestsPath, { recursive: true });
await fs.writeFile(requestPath, JSON.stringify(reviewRequest, null, 2));
// Update status file
await this.updateStatus(reviewRequest);
}
/**
* Update the QA status file (thread-safe via queue)
* @param {Object} reviewRequest - Review request
* @returns {Promise<void>}
*/
async updateStatus(reviewRequest) {
// Chain the update operation onto the queue to serialize concurrent writes
this.statusQueue = this.statusQueue.then(async () => {
let status = {};
try {
status = JSON.parse(await fs.readFile(this.statusPath, 'utf8'));
} catch {
// Create new status
}
status.lastHumanReviewRequest = {
id: reviewRequest.id,
createdAt: reviewRequest.createdAt,
reviewer: reviewRequest.reviewer,
status: reviewRequest.status,
estimatedTime: reviewRequest.estimatedTime,
};
// Update reviewer index for round-robin
const reviewers = ['@architect', '@tech-lead', '@senior-dev'];
const currentIndex = reviewers.indexOf(reviewRequest.reviewer);
if (currentIndex !== -1) {
status.lastReviewerIndex = currentIndex;
}
await fs.mkdir(path.dirname(this.statusPath), { recursive: true });
await fs.writeFile(this.statusPath, JSON.stringify(status, null, 2));
}).catch((err) => {
// Log but don't break the queue for future updates
console.error('Failed to update status:', err.message);
});
// Wait for this update to complete
await this.statusQueue;
}
/**
* Get pending review requests
* @returns {Promise<Array>} Pending requests
*/
async getPendingRequests() {
try {
const files = await fs.readdir(this.reviewRequestsPath);
const requests = [];
for (const file of files) {
if (file.endsWith('.json')) {
const content = await fs.readFile(
path.join(this.reviewRequestsPath, file),
'utf8',
);
const request = JSON.parse(content);
if (request.status === 'pending') {
requests.push(request);
}
}
}
return requests;
} catch {
return [];
}
}
/**
* Complete a review request
* @param {string} requestId - Request ID
* @param {Object} reviewResult - Review result
* @returns {Promise<Object>} Updated request
*/
async completeReview(requestId, reviewResult) {
// Validate ID to prevent path traversal
const validatedId = this.validateRequestId(requestId);
const requestPath = path.join(this.reviewRequestsPath, `${validatedId}.json`);
// Additional containment check
const resolvedPath = path.resolve(requestPath);
const resolvedBase = path.resolve(this.reviewRequestsPath);
if (!resolvedPath.startsWith(resolvedBase + path.sep)) {
throw new Error('Path traversal attempt detected');
}
try {
const request = JSON.parse(await fs.readFile(requestPath, 'utf8'));
request.status = reviewResult.approved ? 'approved' : 'changes_requested';
request.completedAt = new Date().toISOString();
request.reviewResult = reviewResult;
request.actualTime = reviewResult.timeSpent || null;
await fs.writeFile(requestPath, JSON.stringify(request, null, 2));
return request;
} catch (error) {
throw new Error(`Failed to complete review ${requestId}: ${error.message}`);
}
}
}
module.exports = { HumanReviewOrchestrator };