Files
Aperant/apps/frontend/src/main/ipc-handlers/insights-handlers.ts
T
Andy cbd47f2c3a fix(insights): await async sendMessage to prevent race condition (#613) (#773)
* fix(insights): await async sendMessage to prevent race condition (#613)

The IPC handler for INSIGHTS_SEND_MESSAGE was declared async but never
awaited the sendMessage() call. This caused race conditions where
async environment setup (getAPIProfileEnv) wouldn't complete before
the Python process was spawned.

On Windows especially, this led to "Process exited with code 1" errors
because environment variables weren't set in time.

The fix adds await to ensure all async operations complete before
returning, and wraps in try/catch to prevent unhandled rejections.

Fixes #613

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: send errors to UI in catch block

Address Gemini Code Assist feedback - errors caught in the try/catch
block are now also sent to the renderer process via IPC_CHANNELS.INSIGHTS_ERROR.
This ensures all error types (not just executor errors) are reported to the UI.

Co-authored-by: gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: add config.json to gitignore

Prevents worktree configuration files from being accidentally committed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com>
2026-01-07 14:05:11 +01:00

316 lines
10 KiB
TypeScript

import { ipcMain } from 'electron';
import type { BrowserWindow } from 'electron';
import path from 'path';
import { existsSync, readdirSync, mkdirSync, writeFileSync } from 'fs';
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
import type { IPCResult, InsightsSession, InsightsSessionSummary, InsightsModelConfig, Task, TaskMetadata } from '../../shared/types';
import { projectStore } from '../project-store';
import { insightsService } from '../insights-service';
/**
* Register all insights-related IPC handlers
*/
export function registerInsightsHandlers(
getMainWindow: () => BrowserWindow | null
): void {
// ============================================
// Insights Operations
// ============================================
ipcMain.handle(
IPC_CHANNELS.INSIGHTS_GET_SESSION,
async (_, projectId: string): Promise<IPCResult<InsightsSession | null>> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
const session = insightsService.loadSession(projectId, project.path);
return { success: true, data: session };
}
);
ipcMain.on(
IPC_CHANNELS.INSIGHTS_SEND_MESSAGE,
async (_, projectId: string, message: string, modelConfig?: InsightsModelConfig) => {
const project = projectStore.getProject(projectId);
if (!project) {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_ERROR, projectId, 'Project not found');
}
return;
}
// Await the async sendMessage to ensure proper error handling and
// that all async operations (like getProcessEnv) complete before
// the handler returns. This fixes race conditions on Windows where
// environment setup wouldn't complete before process spawn.
try {
await insightsService.sendMessage(projectId, project.path, message, modelConfig);
} catch (error) {
// Errors during sendMessage (executor errors) are already emitted via
// the 'error' event, but we catch here to prevent unhandled rejection
// and ensure all error types are reported to the UI
console.error('[Insights IPC] Error in sendMessage:', error);
const mainWindow = getMainWindow();
if (mainWindow) {
const errorMessage = error instanceof Error ? error.message : String(error);
mainWindow.webContents.send(
IPC_CHANNELS.INSIGHTS_ERROR,
projectId,
`Failed to send message: ${errorMessage}`
);
}
}
}
);
ipcMain.handle(
IPC_CHANNELS.INSIGHTS_CLEAR_SESSION,
async (_, projectId: string): Promise<IPCResult> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
insightsService.clearSession(projectId, project.path);
return { success: true };
}
);
ipcMain.handle(
IPC_CHANNELS.INSIGHTS_CREATE_TASK,
async (
_,
projectId: string,
title: string,
description: string,
metadata?: TaskMetadata
): Promise<IPCResult<Task>> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
if (!project.autoBuildPath) {
return { success: false, error: 'Auto Claude not initialized for this project' };
}
try {
// Generate a unique spec ID based on existing specs
// Get specs directory path
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specsDir = path.join(project.path, specsBaseDir);
// Find next available spec number
let specNumber = 1;
if (existsSync(specsDir)) {
const existingDirs = readdirSync(specsDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
const existingNumbers = existingDirs
.map(name => {
const match = name.match(/^(\d+)/);
return match ? parseInt(match[1], 10) : 0;
})
.filter(n => n > 0);
if (existingNumbers.length > 0) {
specNumber = Math.max(...existingNumbers) + 1;
}
}
// Create spec ID with zero-padded number and slugified title
const slugifiedTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 50);
const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`;
// Create spec directory
const specDir = path.join(specsDir, specId);
mkdirSync(specDir, { recursive: true });
// Build metadata with source type
const taskMetadata: TaskMetadata = {
sourceType: 'insights',
...metadata
};
// Create initial implementation_plan.json
const now = new Date().toISOString();
const implementationPlan = {
feature: title,
description: description,
created_at: now,
updated_at: now,
status: 'pending',
phases: []
};
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2));
// Save task metadata
const metadataPath = path.join(specDir, 'task_metadata.json');
writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2));
// Create the task object
const task: Task = {
id: specId,
specId: specId,
projectId,
title,
description,
status: 'backlog',
subtasks: [],
logs: [],
metadata: taskMetadata,
createdAt: new Date(),
updatedAt: new Date()
};
return { success: true, data: task };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create task'
};
}
}
);
// List all sessions for a project
ipcMain.handle(
IPC_CHANNELS.INSIGHTS_LIST_SESSIONS,
async (_, projectId: string): Promise<IPCResult<InsightsSessionSummary[]>> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
const sessions = insightsService.listSessions(project.path);
return { success: true, data: sessions };
}
);
// Create a new session
ipcMain.handle(
IPC_CHANNELS.INSIGHTS_NEW_SESSION,
async (_, projectId: string): Promise<IPCResult<InsightsSession>> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
const session = insightsService.createNewSession(projectId, project.path);
return { success: true, data: session };
}
);
// Switch to a different session
ipcMain.handle(
IPC_CHANNELS.INSIGHTS_SWITCH_SESSION,
async (_, projectId: string, sessionId: string): Promise<IPCResult<InsightsSession | null>> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
const session = insightsService.switchSession(projectId, project.path, sessionId);
return { success: true, data: session };
}
);
// Delete a session
ipcMain.handle(
IPC_CHANNELS.INSIGHTS_DELETE_SESSION,
async (_, projectId: string, sessionId: string): Promise<IPCResult> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
const success = insightsService.deleteSession(projectId, project.path, sessionId);
if (success) {
return { success: true };
}
return { success: false, error: 'Failed to delete session' };
}
);
// Rename a session
ipcMain.handle(
IPC_CHANNELS.INSIGHTS_RENAME_SESSION,
async (_, projectId: string, sessionId: string, newTitle: string): Promise<IPCResult> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
const success = insightsService.renameSession(project.path, sessionId, newTitle);
if (success) {
return { success: true };
}
return { success: false, error: 'Failed to rename session' };
}
);
// Update model configuration for a session
ipcMain.handle(
IPC_CHANNELS.INSIGHTS_UPDATE_MODEL_CONFIG,
async (_, projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise<IPCResult> => {
const project = projectStore.getProject(projectId);
if (!project) {
return { success: false, error: 'Project not found' };
}
const success = insightsService.updateSessionModelConfig(project.path, sessionId, modelConfig);
if (success) {
return { success: true };
}
return { success: false, error: 'Failed to update model configuration' };
}
);
// ============================================
// Insights Event Forwarding (Service -> Renderer)
// ============================================
// Forward streaming chunks to renderer
insightsService.on('stream-chunk', (projectId: string, chunk: unknown) => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, projectId, chunk);
}
});
// Forward status updates to renderer
insightsService.on('status', (projectId: string, status: unknown) => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_STATUS, projectId, status);
}
});
// Forward errors to renderer
insightsService.on('error', (projectId: string, error: string) => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_ERROR, projectId, error);
}
});
// Forward SDK rate limit events to renderer
insightsService.on('sdk-rate-limit', (rateLimitInfo: unknown) => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
}
});
}