agent-ecosystem/src/main/ipc/review.ts
2026-05-09 14:34:33 +03:00

543 lines
18 KiB
TypeScript

/**
* IPC handlers for code review / diff view feature.
*
* Паттерн: module-level state + guard + wrapReviewHandler (как teams.ts)
*/
import { createIpcWrapper } from '@main/ipc/ipcWrapper';
import { EditorFileWatcher } from '@main/services/editor';
import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore';
import { validateFilePath } from '@main/utils/pathValidation';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import {
REVIEW_APPLY_DECISIONS,
REVIEW_CHECK_CONFLICT,
REVIEW_CLEAR_DECISIONS,
REVIEW_FILE_CHANGE,
REVIEW_GET_AGENT_CHANGES,
REVIEW_GET_CHANGE_STATS,
REVIEW_GET_FILE_CONTENT,
REVIEW_GET_GIT_FILE_LOG,
REVIEW_GET_TASK_CHANGES,
REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES,
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
REVIEW_LOAD_DECISIONS,
REVIEW_PREVIEW_REJECT,
REVIEW_REJECT_FILE,
REVIEW_REJECT_HUNKS,
REVIEW_SAVE_DECISIONS,
REVIEW_SAVE_EDITED_FILE,
REVIEW_UNWATCH_FILES,
REVIEW_WATCH_FILES,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs/promises';
import * as path from 'path';
import type { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import type { FileContentResolver } from '@main/services/team/FileContentResolver';
import type { GitDiffFallback } from '@main/services/team/GitDiffFallback';
import type { ReviewApplierService } from '@main/services/team/ReviewApplierService';
import type { IpcResult } from '@shared/types/ipc';
import type {
AgentChangeSet,
ApplyReviewRequest,
ApplyReviewResult,
ChangeStats,
ConflictCheckResult,
FileChangeWithContent,
HunkDecision,
RejectResult,
SnippetDiff,
TaskChangeRequestOptions,
TaskChangeSetV2,
TeamTaskChangeSummariesResponse,
TeamTaskChangeSummaryRequest,
} from '@shared/types/review';
import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron';
const wrapReviewHandler = createIpcWrapper('IPC:review');
const logger = createLogger('IPC:review');
const TEAM_TASK_CHANGE_SUMMARY_IPC_RAW_REQUEST_LIMIT = 1_000;
const TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT = 201;
// --- Module-level state ---
let changeExtractor: ChangeExtractorService | null = null;
let reviewApplier: ReviewApplierService | null = null;
let fileContentResolver: FileContentResolver | null = null;
let gitDiffFallback: GitDiffFallback | null = null;
const reviewDecisionStore = new ReviewDecisionStore();
const reviewFileWatcher = new EditorFileWatcher();
let reviewWatcherProjectRoot: string | null = null;
let reviewMainWindowRef: BrowserWindow | null = null;
function getChangeExtractor(): ChangeExtractorService {
if (!changeExtractor) throw new Error('Review handlers not initialized');
return changeExtractor;
}
function getApplier(): ReviewApplierService {
if (!reviewApplier) throw new Error('ReviewApplierService not initialized');
return reviewApplier;
}
function getContentResolver(): FileContentResolver {
if (!fileContentResolver) throw new Error('FileContentResolver not initialized');
return fileContentResolver;
}
// --- Forward-compatible config object ---
export interface ReviewHandlerDeps {
extractor: ChangeExtractorService;
applier?: ReviewApplierService;
contentResolver?: FileContentResolver;
gitFallback?: GitDiffFallback;
}
export function initializeReviewHandlers(deps: ReviewHandlerDeps): void {
changeExtractor = deps.extractor;
if (deps.applier) reviewApplier = deps.applier;
if (deps.contentResolver) fileContentResolver = deps.contentResolver;
if (deps.gitFallback) gitDiffFallback = deps.gitFallback;
}
export function registerReviewHandlers(ipcMain: IpcMain): void {
// Phase 1
ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges);
ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges);
ipcMain.handle(REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES, handleGetTeamTaskChangeSummaries);
ipcMain.handle(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, handleInvalidateTaskChangeSummaries);
ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats);
// Phase 2
ipcMain.handle(REVIEW_CHECK_CONFLICT, handleCheckConflict);
ipcMain.handle(REVIEW_REJECT_HUNKS, handleRejectHunks);
ipcMain.handle(REVIEW_REJECT_FILE, handleRejectFile);
ipcMain.handle(REVIEW_PREVIEW_REJECT, handlePreviewReject);
ipcMain.handle(REVIEW_APPLY_DECISIONS, handleApplyDecisions);
ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent);
// Editable diff
ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile);
ipcMain.handle(REVIEW_WATCH_FILES, handleWatchReviewFiles);
ipcMain.handle(REVIEW_UNWATCH_FILES, handleUnwatchReviewFiles);
// Phase 4
ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog);
// Decision persistence
ipcMain.handle(REVIEW_LOAD_DECISIONS, handleLoadDecisions);
ipcMain.handle(REVIEW_SAVE_DECISIONS, handleSaveDecisions);
ipcMain.handle(REVIEW_CLEAR_DECISIONS, handleClearDecisions);
}
export function removeReviewHandlers(ipcMain: IpcMain): void {
// Phase 1
ipcMain.removeHandler(REVIEW_GET_AGENT_CHANGES);
ipcMain.removeHandler(REVIEW_GET_TASK_CHANGES);
ipcMain.removeHandler(REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES);
ipcMain.removeHandler(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES);
ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS);
// Phase 2
ipcMain.removeHandler(REVIEW_CHECK_CONFLICT);
ipcMain.removeHandler(REVIEW_REJECT_HUNKS);
ipcMain.removeHandler(REVIEW_REJECT_FILE);
ipcMain.removeHandler(REVIEW_PREVIEW_REJECT);
ipcMain.removeHandler(REVIEW_APPLY_DECISIONS);
ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT);
// Editable diff
ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE);
ipcMain.removeHandler(REVIEW_WATCH_FILES);
ipcMain.removeHandler(REVIEW_UNWATCH_FILES);
// Phase 4
ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG);
// Decision persistence
ipcMain.removeHandler(REVIEW_LOAD_DECISIONS);
ipcMain.removeHandler(REVIEW_SAVE_DECISIONS);
ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS);
reviewFileWatcher.stop();
reviewWatcherProjectRoot = null;
}
export function setReviewMainWindow(win: BrowserWindow | null): void {
reviewMainWindowRef = win;
}
// --- Phase 1 Handlers ---
async function handleGetAgentChanges(
_event: IpcMainInvokeEvent,
teamName: string,
memberName: string
): Promise<IpcResult<AgentChangeSet>> {
return wrapReviewHandler('getAgentChanges', () =>
getChangeExtractor().getAgentChanges(teamName, memberName)
);
}
function sanitizeTaskChangeOptions(options?: unknown): TaskChangeRequestOptions | undefined {
if (!options || typeof options !== 'object') {
return undefined;
}
const raw = options as Record<string, unknown>;
return {
owner: typeof raw.owner === 'string' ? raw.owner : undefined,
status: typeof raw.status === 'string' ? raw.status : undefined,
since: typeof raw.since === 'string' ? raw.since : undefined,
intervals: Array.isArray(raw.intervals)
? (raw.intervals.filter(
(i): i is { startedAt: string; completedAt?: string } =>
Boolean(i) &&
typeof i === 'object' &&
typeof (i as Record<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
) as { startedAt: string; completedAt?: string }[])
: undefined,
stateBucket:
raw.stateBucket === 'approved' ||
raw.stateBucket === 'review' ||
raw.stateBucket === 'completed' ||
raw.stateBucket === 'active'
? raw.stateBucket
: undefined,
summaryOnly: raw.summaryOnly === true,
forceFresh: raw.forceFresh === true,
};
}
function sanitizeTeamTaskChangeSummaryRequests(requests: unknown): TeamTaskChangeSummaryRequest[] {
if (!Array.isArray(requests)) {
return [];
}
const sanitizedRequests: TeamTaskChangeSummaryRequest[] = [];
const seenTaskIds = new Set<string>();
for (const request of requests.slice(0, TEAM_TASK_CHANGE_SUMMARY_IPC_RAW_REQUEST_LIMIT)) {
if (sanitizedRequests.length >= TEAM_TASK_CHANGE_SUMMARY_IPC_UNIQUE_REQUEST_LIMIT) {
break;
}
if (!request || typeof request !== 'object') {
continue;
}
const raw = request as Record<string, unknown>;
if (typeof raw.taskId !== 'string') {
continue;
}
const taskId = raw.taskId.trim();
if (!taskId || seenTaskIds.has(taskId)) {
continue;
}
seenTaskIds.add(taskId);
sanitizedRequests.push({
taskId,
options: sanitizeTaskChangeOptions(raw.options),
});
}
return sanitizedRequests;
}
async function handleGetTaskChanges(
_event: IpcMainInvokeEvent,
teamName: string,
taskId: string,
options?: unknown
): Promise<IpcResult<TaskChangeSetV2>> {
const opts = sanitizeTaskChangeOptions(options);
return wrapReviewHandler('getTaskChanges', () =>
getChangeExtractor().getTaskChanges(teamName, taskId, opts)
);
}
async function handleGetTeamTaskChangeSummaries(
_event: IpcMainInvokeEvent,
teamName: string,
requests: unknown
): Promise<IpcResult<TeamTaskChangeSummariesResponse>> {
const sanitizedRequests = sanitizeTeamTaskChangeSummaryRequests(requests);
return wrapReviewHandler('getTeamTaskChangeSummaries', () =>
getChangeExtractor().getTeamTaskChangeSummaries(teamName, sanitizedRequests)
);
}
async function handleInvalidateTaskChangeSummaries(
_event: IpcMainInvokeEvent,
teamName: string,
taskIds: string[]
): Promise<IpcResult<void>> {
return wrapReviewHandler('invalidateTaskChangeSummaries', async () => {
await getChangeExtractor().invalidateTaskChangeSummaries(
teamName,
Array.isArray(taskIds) ? taskIds.filter((taskId) => typeof taskId === 'string') : []
);
});
}
async function handleGetChangeStats(
_event: IpcMainInvokeEvent,
teamName: string,
memberName: string
): Promise<IpcResult<ChangeStats>> {
return wrapReviewHandler('getChangeStats', () =>
getChangeExtractor().getChangeStats(teamName, memberName)
);
}
// --- Phase 2 Handlers ---
async function handleCheckConflict(
_event: IpcMainInvokeEvent,
filePath: string,
expectedModified: string
): Promise<IpcResult<ConflictCheckResult>> {
return wrapReviewHandler('checkConflict', () =>
getApplier().checkConflict(filePath, expectedModified)
);
}
async function handleRejectHunks(
_event: IpcMainInvokeEvent,
teamName: string,
filePath: string,
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Promise<IpcResult<RejectResult>> {
return wrapReviewHandler('rejectHunks', () =>
getApplier().rejectHunks(teamName, filePath, original, modified, hunkIndices, snippets)
);
}
async function handleRejectFile(
_event: IpcMainInvokeEvent,
teamName: string,
filePath: string,
original: string,
modified: string
): Promise<IpcResult<RejectResult>> {
return wrapReviewHandler('rejectFile', () =>
getApplier().rejectFile(teamName, filePath, original, modified)
);
}
async function handlePreviewReject(
_event: IpcMainInvokeEvent,
filePath: string,
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Promise<IpcResult<{ preview: string; hasConflicts: boolean }>> {
return wrapReviewHandler('previewReject', () =>
getApplier().previewReject(filePath, original, modified, hunkIndices, snippets)
);
}
async function handleApplyDecisions(
_event: IpcMainInvokeEvent,
request: ApplyReviewRequest
): Promise<IpcResult<ApplyReviewResult>> {
if (!request || !Array.isArray(request.decisions)) {
return { success: false, error: 'Invalid request: decisions array required' };
}
return wrapReviewHandler('applyDecisions', async () => {
// Build file contents map for the applier. Prefer renderer-provided context
// (snippets + full contents), falling back to resolver when missing.
const fileContents = new Map<string, FileChangeWithContent>();
const memberName = request.memberName ?? '';
for (const d of request.decisions) {
const snippets = d.snippets ?? [];
// If renderer provided full contents, use them directly.
if (d.originalFullContent !== undefined || d.modifiedFullContent !== undefined) {
fileContents.set(d.filePath, {
filePath: d.filePath,
relativePath: d.filePath.split(/[\\/]/).filter(Boolean).slice(-3).join('/'),
snippets,
linesAdded: 0,
linesRemoved: 0,
isNewFile: d.isNewFile ?? snippets.some((s) => s.type === 'write-new'),
originalFullContent: d.originalFullContent ?? null,
modifiedFullContent: d.modifiedFullContent ?? null,
// Source is informational only; "unavailable" avoids lying.
contentSource: 'unavailable',
});
continue;
}
// Fallback: resolve in main process (best-effort; task mode may not have memberName).
const resolved = await getContentResolver().getFileContent(
request.teamName,
memberName,
d.filePath,
snippets
);
fileContents.set(d.filePath, resolved);
}
const result = await getApplier().applyReviewDecisions(request, fileContents);
// Invalidate resolved file content cache after applying decisions so subsequent
// diff operations read the latest disk state (avoids "stuck" decisions in instant-apply flows).
try {
for (const d of request.decisions) {
getContentResolver().invalidateFile(d.filePath);
}
} catch (error) {
logger.debug('applyDecisions cache invalidation failed:', error);
}
return result;
});
}
async function handleGetFileContent(
_event: IpcMainInvokeEvent,
teamName: string,
memberName: string,
filePath: string,
snippets: SnippetDiff[] = []
): Promise<IpcResult<FileChangeWithContent>> {
return wrapReviewHandler('getFileContent', () =>
getContentResolver().getFileContent(teamName, memberName, filePath, snippets)
);
}
// --- Editable diff Handlers ---
async function handleSaveEditedFile(
_event: IpcMainInvokeEvent,
filePath: string,
content: string,
projectPath?: string
): Promise<IpcResult<{ success: boolean }>> {
if (!filePath || typeof content !== 'string') {
return { success: false, error: 'Invalid parameters' };
}
const resolvedProjectPath = projectPath && typeof projectPath === 'string' ? projectPath : null;
const pathCheck = validateFilePath(filePath, resolvedProjectPath);
if (!pathCheck.valid) {
logger.error(`saveEditedFile blocked: ${String(pathCheck.error)} (path: ${String(filePath)})`);
return { success: false, error: `Path validation failed: ${String(pathCheck.error)}` };
}
return wrapReviewHandler('saveEditedFile', async () => {
const result = await getApplier().saveEditedFile(pathCheck.normalizedPath!, content);
// Invalidate cached content so next fetch reads the saved version from disk
getContentResolver().invalidateFile(pathCheck.normalizedPath!);
return result;
});
}
async function handleWatchReviewFiles(
_event: IpcMainInvokeEvent,
projectPath: string,
filePaths: string[]
): Promise<IpcResult<void>> {
return wrapReviewHandler('watchFiles', async () => {
const normalizedProjectPath = await validateReviewProjectPath(projectPath);
const shouldRestart =
reviewWatcherProjectRoot !== normalizedProjectPath || !reviewFileWatcher.isWatching();
if (shouldRestart) {
reviewFileWatcher.stop();
reviewWatcherProjectRoot = normalizedProjectPath;
reviewFileWatcher.start(normalizedProjectPath, (event) => {
safeSendToRenderer(reviewMainWindowRef, REVIEW_FILE_CHANGE, event);
});
}
reviewFileWatcher.setWatchedFiles(Array.isArray(filePaths) ? filePaths : []);
});
}
async function handleUnwatchReviewFiles(): Promise<IpcResult<void>> {
return wrapReviewHandler('unwatchFiles', async () => {
reviewFileWatcher.stop();
reviewWatcherProjectRoot = null;
});
}
// --- Phase 4 Handlers ---
async function validateReviewProjectPath(projectPath: string): Promise<string> {
if (!projectPath || typeof projectPath !== 'string') {
throw new Error('Invalid project path');
}
if (!path.isAbsolute(projectPath)) {
throw new Error('Project path must be absolute');
}
const normalized = path.resolve(path.normalize(projectPath));
const stat = await fs.stat(normalized);
if (!stat.isDirectory()) {
throw new Error('Project path is not a directory');
}
return normalized;
}
async function handleGetGitFileLog(
_event: IpcMainInvokeEvent,
projectPath: string,
filePath: string
): Promise<IpcResult<{ hash: string; timestamp: string; message: string }[]>> {
return wrapReviewHandler('getGitFileLog', async () => {
if (!gitDiffFallback) {
return [];
}
return gitDiffFallback.getFileLog(projectPath, filePath);
});
}
// --- Decision Persistence Handlers ---
async function handleLoadDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string,
scopeToken: string | null = null
): Promise<
IpcResult<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null>
> {
return wrapReviewHandler('loadDecisions', () =>
reviewDecisionStore.load(teamName, scopeKey, scopeToken ?? undefined)
);
}
async function handleSaveDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string,
scopeToken: string,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>,
hunkContextHashesByFile: Record<string, Record<number, string>> | null = null
): Promise<IpcResult<void>> {
return wrapReviewHandler('saveDecisions', () =>
reviewDecisionStore.save(teamName, scopeKey, {
scopeToken,
hunkDecisions,
fileDecisions,
hunkContextHashesByFile: hunkContextHashesByFile ?? undefined,
})
);
}
async function handleClearDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string,
scopeToken: string | null = null
): Promise<IpcResult<void>> {
return wrapReviewHandler('clearDecisions', () =>
reviewDecisionStore.clear(teamName, scopeKey, scopeToken ?? undefined)
);
}