diff --git a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts index c969ca335a..2d0b590184 100644 --- a/apps/frontend/src/main/__tests__/ipc-handlers.test.ts +++ b/apps/frontend/src/main/__tests__/ipc-handlers.test.ts @@ -2,86 +2,86 @@ * Unit tests for IPC handlers * Tests all IPC communication patterns between main and renderer processes */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { EventEmitter } from 'events'; -import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync } from 'fs'; -import { tmpdir } from 'os'; -import path from 'path'; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "events"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; // Test data directory -const TEST_DIR = mkdtempSync(path.join(tmpdir(), 'ipc-handlers-test-')); -const TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project'); +const TEST_DIR = mkdtempSync(path.join(tmpdir(), "ipc-handlers-test-")); +const TEST_PROJECT_PATH = path.join(TEST_DIR, "test-project"); // Mock electron-updater before importing -vi.mock('electron-updater', () => ({ +vi.mock("electron-updater", () => ({ autoUpdater: { autoDownload: true, autoInstallOnAppQuit: true, on: vi.fn(), checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve()), - quitAndInstall: vi.fn() - } + quitAndInstall: vi.fn(), + }, })); // Mock @electron-toolkit/utils before importing -vi.mock('@electron-toolkit/utils', () => ({ +vi.mock("@electron-toolkit/utils", () => ({ is: { dev: true, - windows: process.platform === 'win32', - macos: process.platform === 'darwin', - linux: process.platform === 'linux' + windows: process.platform === "win32", + macos: process.platform === "darwin", + linux: process.platform === "linux", }, electronApp: { - setAppUserModelId: vi.fn() + setAppUserModelId: vi.fn(), }, optimizer: { - watchWindowShortcuts: vi.fn() - } + watchWindowShortcuts: vi.fn(), + }, })); // Mock version-manager to return a predictable version -vi.mock('../updater/version-manager', () => ({ - getEffectiveVersion: vi.fn(() => '0.1.0'), - getBundledVersion: vi.fn(() => '0.1.0'), - parseVersionFromTag: vi.fn((tag: string) => tag.replace('v', '')), - compareVersions: vi.fn(() => 0) +vi.mock("../updater/version-manager", () => ({ + getEffectiveVersion: vi.fn(() => "0.1.0"), + getBundledVersion: vi.fn(() => "0.1.0"), + parseVersionFromTag: vi.fn((tag: string) => tag.replace("v", "")), + compareVersions: vi.fn(() => 0), })); -vi.mock('../notification-service', () => ({ +vi.mock("../notification-service", () => ({ notificationService: { initialize: vi.fn(), notifyReviewNeeded: vi.fn(), - notifyTaskFailed: vi.fn() - } + notifyTaskFailed: vi.fn(), + }, })); // Mock electron-log to prevent Electron binary dependency -vi.mock('electron-log/main.js', () => ({ +vi.mock("electron-log/main.js", () => ({ default: { initialize: vi.fn(), transports: { file: { maxSize: 10 * 1024 * 1024, - format: '', - fileName: 'main.log', - level: 'info', - getFile: vi.fn(() => ({ path: '/tmp/test.log' })) + format: "", + fileName: "main.log", + level: "info", + getFile: vi.fn(() => ({ path: "/tmp/test.log" })), }, console: { - level: 'warn', - format: '' - } + level: "warn", + format: "", + }, }, debug: vi.fn(), info: vi.fn(), warn: vi.fn(), - error: vi.fn() - } + error: vi.fn(), + }, })); // Mock modules before importing -vi.mock('electron', () => { +vi.mock("electron", () => { const mockIpcMain = new (class extends EventEmitter { private handlers: Map = new Map(); @@ -109,27 +109,29 @@ vi.mock('electron', () => { return { app: { getPath: vi.fn((name: string) => { - if (name === 'userData') return path.join(TEST_DIR, 'userData'); + if (name === "userData") return path.join(TEST_DIR, "userData"); return TEST_DIR; }), getAppPath: vi.fn(() => TEST_DIR), - getVersion: vi.fn(() => '0.1.0'), - isPackaged: false + getVersion: vi.fn(() => "0.1.0"), + isPackaged: false, }, ipcMain: mockIpcMain, dialog: { - showOpenDialog: vi.fn(() => Promise.resolve({ canceled: false, filePaths: [TEST_PROJECT_PATH] })) + showOpenDialog: vi.fn(() => + Promise.resolve({ canceled: false, filePaths: [TEST_PROJECT_PATH] }) + ), }, BrowserWindow: class { webContents = { send: vi.fn() }; - } + }, }; }); // Setup test project structure function setupTestProject(): void { mkdirSync(TEST_PROJECT_PATH, { recursive: true }); - mkdirSync(path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs'), { recursive: true }); + mkdirSync(path.join(TEST_PROJECT_PATH, "auto-claude", "specs"), { recursive: true }); } // Cleanup test directories @@ -140,7 +142,7 @@ function cleanupTestDirs(): void { } // Increase timeout for all tests in this file due to dynamic imports and setup overhead -describe('IPC Handlers', { timeout: 15000 }, () => { +describe("IPC Handlers", { timeout: 15000 }, () => { let ipcMain: EventEmitter & { handlers: Map; invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise; @@ -171,16 +173,20 @@ describe('IPC Handlers', { timeout: 15000 }, () => { beforeEach(async () => { cleanupTestDirs(); setupTestProject(); - mkdirSync(path.join(TEST_DIR, 'userData', 'store'), { recursive: true }); + mkdirSync(path.join(TEST_DIR, "userData", "store"), { recursive: true }); // Get mocked ipcMain - const electron = await import('electron'); + const electron = await import("electron"); ipcMain = electron.ipcMain as unknown as typeof ipcMain; - // Create mock window + // Create mock window with isDestroyed methods for safeSendToRenderer mockMainWindow = { - webContents: { send: vi.fn() } - }; + isDestroyed: vi.fn(() => false), + webContents: { + send: vi.fn(), + isDestroyed: vi.fn(() => false), + }, + } as { webContents: { send: ReturnType }; isDestroyed: () => boolean }; // Create mock agent manager mockAgentManager = Object.assign(new EventEmitter(), { @@ -188,7 +194,7 @@ describe('IPC Handlers', { timeout: 15000 }, () => { startTaskExecution: vi.fn(), startQAProcess: vi.fn(), killTask: vi.fn(), - configure: vi.fn() + configure: vi.fn(), }); // Create mock terminal manager @@ -198,13 +204,27 @@ describe('IPC Handlers', { timeout: 15000 }, () => { write: vi.fn(), resize: vi.fn(), invokeClaude: vi.fn(), - killAll: vi.fn(() => Promise.resolve()) + killAll: vi.fn(() => Promise.resolve()), }; mockPythonEnvManager = { on: vi.fn(), - initialize: vi.fn(() => Promise.resolve({ ready: true, pythonPath: '/usr/bin/python3', venvExists: true, depsInstalled: true })), - getStatus: vi.fn(() => Promise.resolve({ ready: true, pythonPath: '/usr/bin/python3', venvExists: true, depsInstalled: true })) + initialize: vi.fn(() => + Promise.resolve({ + ready: true, + pythonPath: "/usr/bin/python3", + venvExists: true, + depsInstalled: true, + }) + ), + getStatus: vi.fn(() => + Promise.resolve({ + ready: true, + pythonPath: "/usr/bin/python3", + venvExists: true, + depsInstalled: true, + }) + ), }; // Need to reset modules to re-register handlers @@ -216,39 +236,54 @@ describe('IPC Handlers', { timeout: 15000 }, () => { vi.clearAllMocks(); }); - describe('project:add handler', () => { - it('should return error for non-existent path', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + describe("project:add handler", () => { + it("should return error for non-existent path", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); - const result = await ipcMain.invokeHandler('project:add', {}, '/nonexistent/path'); + const result = await ipcMain.invokeHandler("project:add", {}, "/nonexistent/path"); expect(result).toEqual({ success: false, - error: 'Directory does not exist' + error: "Directory does not exist", }); }); - it('should successfully add an existing project', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + it("should successfully add an existing project", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); - const result = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const result = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); - expect(result).toHaveProperty('success', true); - expect(result).toHaveProperty('data'); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("data"); const data = (result as { data: { path: string; name: string } }).data; expect(data.path).toBe(TEST_PROJECT_PATH); - expect(data.name).toBe('test-project'); + expect(data.name).toBe("test-project"); }); - it('should return existing project if already added', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + it("should return existing project if already added", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); // Add project twice - const result1 = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); - const result2 = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const result1 = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); + const result2 = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); const data1 = (result1 as { data: { id: string } }).data; const data2 = (result2 as { data: { id: string } }).data; @@ -256,310 +291,391 @@ describe('IPC Handlers', { timeout: 15000 }, () => { }); }); - describe('project:list handler', () => { - it('should return empty array when no projects', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + describe("project:list handler", () => { + it("should return empty array when no projects", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); - const result = await ipcMain.invokeHandler('project:list', {}); + const result = await ipcMain.invokeHandler("project:list", {}); expect(result).toEqual({ success: true, - data: [] + data: [], }); }); - it('should return all added projects', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + it("should return all added projects", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); // Add a project - await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); - const result = await ipcMain.invokeHandler('project:list', {}); + const result = await ipcMain.invokeHandler("project:list", {}); - expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty("success", true); const data = (result as { data: unknown[] }).data; expect(data).toHaveLength(1); }); }); - describe('project:remove handler', () => { - it('should return false for non-existent project', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + describe("project:remove handler", () => { + it("should return false for non-existent project", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); - const result = await ipcMain.invokeHandler('project:remove', {}, 'nonexistent-id'); + const result = await ipcMain.invokeHandler("project:remove", {}, "nonexistent-id"); expect(result).toEqual({ success: false }); }); - it('should successfully remove an existing project', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + it("should successfully remove an existing project", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); // Add a project first - const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const addResult = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); const projectId = (addResult as { data: { id: string } }).data.id; // Remove it - const removeResult = await ipcMain.invokeHandler('project:remove', {}, projectId); + const removeResult = await ipcMain.invokeHandler("project:remove", {}, projectId); expect(removeResult).toEqual({ success: true }); // Verify it's gone - const listResult = await ipcMain.invokeHandler('project:list', {}); + const listResult = await ipcMain.invokeHandler("project:list", {}); const data = (listResult as { data: unknown[] }).data; expect(data).toHaveLength(0); }); }); - describe('project:updateSettings handler', () => { - it('should return error for non-existent project', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); - - const result = await ipcMain.invokeHandler( - 'project:updateSettings', - {}, - 'nonexistent-id', - { model: 'sonnet' } + describe("project:updateSettings handler", () => { + it("should return error for non-existent project", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never ); + const result = await ipcMain.invokeHandler("project:updateSettings", {}, "nonexistent-id", { + model: "sonnet", + }); + expect(result).toEqual({ success: false, - error: 'Project not found' + error: "Project not found", }); }); - it('should successfully update project settings', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + it("should successfully update project settings", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); // Add a project first - const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const addResult = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); const projectId = (addResult as { data: { id: string } }).data.id; // Update settings - const result = await ipcMain.invokeHandler( - 'project:updateSettings', - {}, - projectId, - { model: 'sonnet', linearSync: true } - ); + const result = await ipcMain.invokeHandler("project:updateSettings", {}, projectId, { + model: "sonnet", + linearSync: true, + }); expect(result).toEqual({ success: true }); }); }); - describe('task:list handler', () => { - it('should return empty array for project with no specs', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + describe("task:list handler", () => { + it("should return empty array for project with no specs", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); // Add a project first - const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const addResult = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); const projectId = (addResult as { data: { id: string } }).data.id; - const result = await ipcMain.invokeHandler('task:list', {}, projectId); + const result = await ipcMain.invokeHandler("task:list", {}, projectId); expect(result).toEqual({ success: true, - data: [] + data: [], }); }); - it('should return tasks when specs exist', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + it("should return tasks when specs exist", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); // Create .auto-claude directory first (before adding project so it gets detected) - mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs'), { recursive: true }); + mkdirSync(path.join(TEST_PROJECT_PATH, ".auto-claude", "specs"), { recursive: true }); // Add a project - it will detect .auto-claude - const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const addResult = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); const projectId = (addResult as { data: { id: string } }).data.id; // Create a spec directory with implementation plan in .auto-claude/specs - const specDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '001-test-feature'); + const specDir = path.join(TEST_PROJECT_PATH, ".auto-claude", "specs", "001-test-feature"); mkdirSync(specDir, { recursive: true }); - writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify({ - feature: 'Test Feature', - workflow_type: 'feature', - services_involved: [], - phases: [{ - phase: 1, - name: 'Test Phase', - type: 'implementation', - subtasks: [{ id: 'subtask-1', description: 'Test subtask', status: 'pending' }] - }], - final_acceptance: [], - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - spec_file: '' - })); - - const result = await ipcMain.invokeHandler('task:list', {}, projectId); - - expect(result).toHaveProperty('success', true); + writeFileSync( + path.join(specDir, "implementation_plan.json"), + JSON.stringify({ + feature: "Test Feature", + workflow_type: "feature", + services_involved: [], + phases: [ + { + phase: 1, + name: "Test Phase", + type: "implementation", + subtasks: [{ id: "subtask-1", description: "Test subtask", status: "pending" }], + }, + ], + final_acceptance: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + spec_file: "", + }) + ); + + const result = await ipcMain.invokeHandler("task:list", {}, projectId); + + expect(result).toHaveProperty("success", true); const data = (result as { data: unknown[] }).data; expect(data).toHaveLength(1); }); }); - describe('task:create handler', () => { - it('should return error for non-existent project', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + describe("task:create handler", () => { + it("should return error for non-existent project", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); const result = await ipcMain.invokeHandler( - 'task:create', + "task:create", {}, - 'nonexistent-id', - 'Test Task', - 'Test description' + "nonexistent-id", + "Test Task", + "Test description" ); expect(result).toEqual({ success: false, - error: 'Project not found' + error: "Project not found", }); }); - it('should create task in backlog status', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + it("should create task in backlog status", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); // Create .auto-claude directory first (before adding project so it gets detected) - mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs'), { recursive: true }); + mkdirSync(path.join(TEST_PROJECT_PATH, ".auto-claude", "specs"), { recursive: true }); // Add a project first - const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const addResult = await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); const projectId = (addResult as { data: { id: string } }).data.id; const result = await ipcMain.invokeHandler( - 'task:create', + "task:create", {}, projectId, - 'Test Task', - 'Test description' + "Test Task", + "Test description" ); - expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty("success", true); // Task is created in backlog status, spec creation starts when task:start is called const task = (result as { data: { status: string } }).data; - expect(task.status).toBe('backlog'); + expect(task.status).toBe("backlog"); }); }); - describe('settings:get handler', () => { - it('should return default settings when no settings file exists', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + describe("settings:get handler", () => { + it("should return default settings when no settings file exists", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); - const result = await ipcMain.invokeHandler('settings:get', {}); + const result = await ipcMain.invokeHandler("settings:get", {}); - expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty("success", true); const data = (result as { data: { theme: string } }).data; - expect(data).toHaveProperty('theme', 'system'); + expect(data).toHaveProperty("theme", "system"); }); }); - describe('settings:save handler', () => { - it('should save settings successfully', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + describe("settings:save handler", () => { + it("should save settings successfully", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); const result = await ipcMain.invokeHandler( - 'settings:save', + "settings:save", {}, - { theme: 'dark', defaultModel: 'opus' } + { theme: "dark", defaultModel: "opus" } ); expect(result).toEqual({ success: true }); // Verify settings were saved - const getResult = await ipcMain.invokeHandler('settings:get', {}); + const getResult = await ipcMain.invokeHandler("settings:get", {}); const data = (getResult as { data: { theme: string; defaultModel: string } }).data; - expect(data.theme).toBe('dark'); - expect(data.defaultModel).toBe('opus'); + expect(data.theme).toBe("dark"); + expect(data.defaultModel).toBe("opus"); }); - it('should configure agent manager when paths change', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); - - await ipcMain.invokeHandler( - 'settings:save', - {}, - { pythonPath: '/usr/bin/python3' } + it("should configure agent manager when paths change", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never ); - expect(mockAgentManager.configure).toHaveBeenCalledWith('/usr/bin/python3', undefined); + await ipcMain.invokeHandler("settings:save", {}, { pythonPath: "/usr/bin/python3" }); + + expect(mockAgentManager.configure).toHaveBeenCalledWith("/usr/bin/python3", undefined); }); }); - describe('app:version handler', () => { - it('should return app version', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + describe("app:version handler", () => { + it("should return app version", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); - const result = await ipcMain.invokeHandler('app:version', {}); + const result = await ipcMain.invokeHandler("app:version", {}); - expect(result).toBe('0.1.0'); + expect(result).toBe("0.1.0"); }); }); - describe('Agent Manager event forwarding', () => { - it('should forward log events to renderer', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + describe("Agent Manager event forwarding", () => { + it("should forward log events to renderer", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); - mockAgentManager.emit('log', 'task-1', 'Test log message'); + mockAgentManager.emit("log", "task-1", "Test log message"); expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( - 'task:log', - 'task-1', - 'Test log message', + "task:log", + "task-1", + "Test log message", undefined // projectId is undefined when task not found ); }); - it('should forward error events to renderer', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + it("should forward error events to renderer", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); - mockAgentManager.emit('error', 'task-1', 'Test error message'); + mockAgentManager.emit("error", "task-1", "Test error message"); expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( - 'task:error', - 'task-1', - 'Test error message', + "task:error", + "task-1", + "Test error message", undefined // projectId is undefined when task not found ); }); - it('should forward exit events with status change on failure', async () => { - const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); + it("should forward exit events with status change on failure", async () => { + const { setupIpcHandlers } = await import("../ipc-handlers"); + setupIpcHandlers( + mockAgentManager as never, + mockTerminalManager as never, + () => mockMainWindow as never, + mockPythonEnvManager as never + ); // Add project first - await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + await ipcMain.invokeHandler("project:add", {}, TEST_PROJECT_PATH); // Create a spec/task directory with implementation_plan.json - const specDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', 'task-1'); + const specDir = path.join(TEST_PROJECT_PATH, ".auto-claude", "specs", "task-1"); mkdirSync(specDir, { recursive: true }); writeFileSync( - path.join(specDir, 'implementation_plan.json'), - JSON.stringify({ feature: 'Test Task', status: 'in_progress' }) + path.join(specDir, "implementation_plan.json"), + JSON.stringify({ feature: "Test Task", status: "in_progress" }) ); - mockAgentManager.emit('exit', 'task-1', 1, 'task-execution'); + mockAgentManager.emit("exit", "task-1", 1, "task-execution"); expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( - 'task:statusChange', - 'task-1', - 'human_review', + "task:statusChange", + "task-1", + "human_review", expect.any(String) // projectId for multi-project filtering ); }); diff --git a/apps/frontend/src/main/__tests__/utils.test.ts b/apps/frontend/src/main/__tests__/utils.test.ts new file mode 100644 index 0000000000..1ab513eb6f --- /dev/null +++ b/apps/frontend/src/main/__tests__/utils.test.ts @@ -0,0 +1,479 @@ +/** + * IPC Utils Tests + * ================== + * Tests for safeSendToRenderer helper function that prevents + * "Render frame was disposed" errors when sending IPC messages + * from main process to renderer. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { BrowserWindow } from "electron"; + +describe("safeSendToRenderer", () => { + let mockWindow: BrowserWindow | null; + let getMainWindow: () => BrowserWindow | null; + let mockSend: ReturnType; + let safeSendToRenderer: typeof import("../ipc-handlers/utils").safeSendToRenderer; + + beforeEach(async () => { + mockSend = vi.fn(); + + // Clear module-level state before each test to ensure clean state + // This is especially important for the warnTimestamps Map which is shared across tests + const { _clearWarnTimestampsForTest } = await import("../ipc-handlers/utils"); + _clearWarnTimestampsForTest(); + + // Create a mock window with valid webContents + mockWindow = { + isDestroyed: vi.fn(() => false), + webContents: { + isDestroyed: vi.fn(() => false), + send: mockSend, + }, + } as unknown as BrowserWindow; + + getMainWindow = () => mockWindow; + + // Dynamic import to get fresh module state for each test + const utilsModule = await import("../ipc-handlers/utils"); + safeSendToRenderer = utilsModule.safeSendToRenderer; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("when mainWindow is null", () => { + it("returns false and does not send", () => { + getMainWindow = () => null; + + const result = safeSendToRenderer(getMainWindow, "test-channel", "arg1", "arg2"); + + expect(result).toBe(false); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + describe("when window is destroyed", () => { + it("returns false and does not send", () => { + mockWindow = { + isDestroyed: vi.fn(() => true), + webContents: { + isDestroyed: vi.fn(() => false), + send: mockSend, + }, + } as unknown as BrowserWindow; + getMainWindow = () => mockWindow; + + const result = safeSendToRenderer(getMainWindow, "test-channel", "data"); + + expect(result).toBe(false); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + describe("when webContents is destroyed", () => { + it("returns false and does not send", () => { + mockWindow = { + isDestroyed: vi.fn(() => false), + webContents: { + isDestroyed: vi.fn(() => true), + send: mockSend, + }, + } as unknown as BrowserWindow; + getMainWindow = () => mockWindow; + + const result = safeSendToRenderer(getMainWindow, "test-channel", "data"); + + expect(result).toBe(false); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + describe("when webContents is null", () => { + it("returns false and does not send", () => { + mockWindow = { + isDestroyed: vi.fn(() => false), + webContents: null, + } as unknown as BrowserWindow; + getMainWindow = () => mockWindow; + + const result = safeSendToRenderer(getMainWindow, "test-channel", "data"); + + expect(result).toBe(false); + expect(mockSend).not.toHaveBeenCalled(); + }); + }); + + describe("when window and webContents are valid", () => { + it("returns true and sends message with correct arguments", () => { + const result = safeSendToRenderer( + getMainWindow, + "test-channel", + "arg1", + { key: "value" }, + 42 + ); + + expect(result).toBe(true); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith("test-channel", "arg1", { key: "value" }, 42); + }); + + it("sends message with no arguments", () => { + const result = safeSendToRenderer(getMainWindow, "test-channel"); + + expect(result).toBe(true); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith("test-channel"); + }); + + it("sends multiple messages successfully", () => { + const result1 = safeSendToRenderer(getMainWindow, "channel-1", "data1"); + const result2 = safeSendToRenderer(getMainWindow, "channel-2", "data2"); + + expect(result1).toBe(true); + expect(result2).toBe(true); + expect(mockSend).toHaveBeenCalledTimes(2); + expect(mockSend).toHaveBeenNthCalledWith(1, "channel-1", "data1"); + expect(mockSend).toHaveBeenNthCalledWith(2, "channel-2", "data2"); + }); + }); + + describe("error handling - disposal errors", () => { + it("catches disposal errors and returns false", () => { + // Mock send to throw a disposal error + mockSend.mockImplementation(() => { + throw new Error("Render frame was disposed before WebFrameMain could be accessed"); + }); + + const result = safeSendToRenderer(getMainWindow, "test-channel", "data"); + + expect(result).toBe(false); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('catches generic "disposed" errors and returns false', () => { + mockSend.mockImplementation(() => { + throw new Error("Object has been destroyed"); + }); + + const result = safeSendToRenderer(getMainWindow, "test-channel", "data"); + + expect(result).toBe(false); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('catches "destroyed" errors and returns false', () => { + mockSend.mockImplementation(() => { + throw new Error("WebContents was destroyed"); + }); + + const result = safeSendToRenderer(getMainWindow, "test-channel", "data"); + + expect(result).toBe(false); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + }); + + describe("error handling - non-disposal errors", () => { + it("catches other errors and returns false", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + mockSend.mockImplementation(() => { + throw new Error("Some other IPC error"); + }); + + const result = safeSendToRenderer(getMainWindow, "test-channel", "data"); + + expect(result).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe("warning cooldown behavior", () => { + it("returns false for multiple consecutive calls to destroyed windows", () => { + mockWindow = { + isDestroyed: vi.fn(() => true), + webContents: { + isDestroyed: vi.fn(() => false), + send: mockSend, + }, + } as unknown as BrowserWindow; + getMainWindow = () => mockWindow; + + // Multiple calls should all return false without throwing + const result1 = safeSendToRenderer(getMainWindow, "test-channel", "data1"); + const result2 = safeSendToRenderer(getMainWindow, "test-channel", "data2"); + const result3 = safeSendToRenderer(getMainWindow, "test-channel", "data3"); + + expect(result1).toBe(false); + expect(result2).toBe(false); + expect(result3).toBe(false); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it("logs console.warn only once for multiple consecutive calls to same channel", () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + mockWindow = { + isDestroyed: vi.fn(() => true), + webContents: { + isDestroyed: vi.fn(() => false), + send: mockSend, + }, + } as unknown as BrowserWindow; + getMainWindow = () => mockWindow; + + // Multiple calls to same channel - should warn only once + safeSendToRenderer(getMainWindow, "test-channel", "data1"); + safeSendToRenderer(getMainWindow, "test-channel", "data2"); + safeSendToRenderer(getMainWindow, "test-channel", "data3"); + + // console.warn should be called exactly once + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Skipping send to destroyed window: test-channel") + ); + + consoleWarnSpy.mockRestore(); + }); + + it("logs console.warn separately for different channels", () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + mockWindow = { + isDestroyed: vi.fn(() => true), + webContents: { + isDestroyed: vi.fn(() => false), + send: mockSend, + }, + } as unknown as BrowserWindow; + getMainWindow = () => mockWindow; + + // Different channels - each should warn once + safeSendToRenderer(getMainWindow, "channel-a", "data"); + safeSendToRenderer(getMainWindow, "channel-b", "data"); + safeSendToRenderer(getMainWindow, "channel-c", "data"); + + // console.warn should be called once per channel (3 times total) + expect(consoleWarnSpy).toHaveBeenCalledTimes(3); + + consoleWarnSpy.mockRestore(); + }); + + it("handles different channels independently", () => { + mockWindow = { + isDestroyed: vi.fn(() => true), + webContents: { + isDestroyed: vi.fn(() => false), + send: mockSend, + }, + } as unknown as BrowserWindow; + getMainWindow = () => mockWindow; + + // Different channels should all return false + const result1 = safeSendToRenderer(getMainWindow, "channel-a", "data"); + const result2 = safeSendToRenderer(getMainWindow, "channel-b", "data"); + const result3 = safeSendToRenderer(getMainWindow, "channel-c", "data"); + + expect(result1).toBe(false); + expect(result2).toBe(false); + expect(result3).toBe(false); + }); + }); + + describe("race condition - frame disposal between check and send", () => { + it("handles disposal that occurs after validation but before send", () => { + // First call succeeds + let callCount = 0; + mockSend.mockImplementation(() => { + callCount++; + if (callCount > 1) { + throw new Error("Render frame was disposed"); + } + }); + + const result1 = safeSendToRenderer(getMainWindow, "test-channel", "data1"); + expect(result1).toBe(true); + + // Second call throws disposal error but is caught + const result2 = safeSendToRenderer(getMainWindow, "test-channel", "data2"); + expect(result2).toBe(false); + }); + }); + + describe("warning pruning logic - 100-entry hard cap", () => { + it("enforces 100-entry cap by removing oldest entries when exceeded", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + mockWindow = { + isDestroyed: vi.fn(() => true), + webContents: { + isDestroyed: vi.fn(() => false), + send: mockSend, + }, + } as unknown as BrowserWindow; + getMainWindow = () => mockWindow; + + // Add 105 unique channels - this triggers pruning + for (let i = 0; i < 105; i++) { + safeSendToRenderer(getMainWindow, `channel-${i}`, `data-${i}`); + } + + // Should have warned for all 105 unique channels + expect(consoleWarnSpy).toHaveBeenCalledTimes(105); + + // Verify that calling the same channel multiple times within cooldown period + // only warns once (test the cooldown mechanism) + consoleWarnSpy.mockClear(); + safeSendToRenderer(getMainWindow, "channel-0", "data-again"); + safeSendToRenderer(getMainWindow, "channel-0", "data-again"); + safeSendToRenderer(getMainWindow, "channel-0", "data-again"); + + // Should only warn once due to cooldown + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + + consoleWarnSpy.mockRestore(); + }); + + it("handles many unique channels without throwing errors", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + mockWindow = { + isDestroyed: vi.fn(() => true), + webContents: { + isDestroyed: vi.fn(() => false), + send: mockSend, + }, + } as unknown as BrowserWindow; + getMainWindow = () => mockWindow; + + // Add 200 unique channels - should trigger pruning multiple times + // This tests that the pruning logic doesn't throw errors + expect(() => { + for (let i = 0; i < 200; i++) { + safeSendToRenderer(getMainWindow, `channel-${i}`, `data-${i}`); + } + }).not.toThrow(); + + // Should have warned for all 200 unique channels + expect(consoleWarnSpy).toHaveBeenCalledTimes(200); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe("parseEnvFile", () => { + it("parses Unix line endings (LF)", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const content = "KEY1=value1\nKEY2=value2\nKEY3=value3"; + const result = parseEnvFile(content); + + expect(result).toEqual({ + KEY1: "value1", + KEY2: "value2", + KEY3: "value3", + }); + }); + + it("parses Windows line endings (CRLF)", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const content = "KEY1=value1\r\nKEY2=value2\r\nKEY3=value3"; + const result = parseEnvFile(content); + + expect(result).toEqual({ + KEY1: "value1", + KEY2: "value2", + KEY3: "value3", + }); + }); + + it("parses mixed line endings", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const content = "KEY1=value1\nKEY2=value2\r\nKEY3=value3\nKEY4=value4"; + const result = parseEnvFile(content); + + expect(result).toEqual({ + KEY1: "value1", + KEY2: "value2", + KEY3: "value3", + KEY4: "value4", + }); + }); + + it("handles empty lines", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const content = "KEY1=value1\n\nKEY2=value2\r\n\r\nKEY3=value3"; + const result = parseEnvFile(content); + + expect(result).toEqual({ + KEY1: "value1", + KEY2: "value2", + KEY3: "value3", + }); + }); + + it("handles comments", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const content = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2"; + const result = parseEnvFile(content); + + expect(result).toEqual({ + KEY1: "value1", + KEY2: "value2", + }); + }); + + it("handles quoted values", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const content = "KEY1=\"value with spaces\"\nKEY2='single quotes'\nKEY3=unquoted"; + const result = parseEnvFile(content); + + expect(result).toEqual({ + KEY1: "value with spaces", + KEY2: "single quotes", + KEY3: "unquoted", + }); + }); + + it("handles values with equals signs", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const content = "KEY1=value=with=equals\nKEY2=simple"; + const result = parseEnvFile(content); + + expect(result).toEqual({ + KEY1: "value=with=equals", + KEY2: "simple", + }); + }); + + it("handles empty input", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const result = parseEnvFile(""); + + expect(result).toEqual({}); + }); + + it("handles only comments and empty lines", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const content = "# Comment 1\n# Comment 2\n\n\n"; + const result = parseEnvFile(content); + + expect(result).toEqual({}); + }); + + it("trims whitespace from keys and values", async () => { + const { parseEnvFile } = await import("../ipc-handlers/utils"); + const content = " KEY1 = value1 \nKEY2=value2"; + const result = parseEnvFile(content); + + expect(result).toEqual({ + KEY1: "value1", + KEY2: "value2", + }); + }); + }); +}); diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index eb208ff0f6..3cb23d30d7 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -1,25 +1,31 @@ -import type { BrowserWindow } from 'electron'; -import path from 'path'; -import { existsSync } from 'fs'; -import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants'; -import { wouldPhaseRegress, isTerminalPhase, isValidExecutionPhase, isValidPhaseTransition, type ExecutionPhase } from '../../shared/constants/phase-protocol'; +import type { BrowserWindow } from "electron"; +import path from "path"; +import { existsSync } from "fs"; +import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from "../../shared/constants"; +import { + wouldPhaseRegress, + isTerminalPhase, + isValidExecutionPhase, + isValidPhaseTransition, + type ExecutionPhase, +} from "../../shared/constants/phase-protocol"; import type { SDKRateLimitInfo, Task, TaskStatus, Project, - ImplementationPlan -} from '../../shared/types'; -import { AgentManager } from '../agent'; -import type { ProcessType, ExecutionProgressData } from '../agent'; -import { titleGenerator } from '../title-generator'; -import { fileWatcher } from '../file-watcher'; -import { projectStore } from '../project-store'; -import { notificationService } from '../notification-service'; -import { persistPlanStatusSync, getPlanPath } from './task/plan-file-utils'; -import { findTaskWorktree } from '../worktree-paths'; -import { findTaskAndProject } from './task/shared'; - + ImplementationPlan, +} from "../../shared/types"; +import { AgentManager } from "../agent"; +import type { ProcessType, ExecutionProgressData } from "../agent"; +import { titleGenerator } from "../title-generator"; +import { fileWatcher } from "../file-watcher"; +import { projectStore } from "../project-store"; +import { notificationService } from "../notification-service"; +import { persistPlanStatusSync, getPlanPath } from "./task/plan-file-utils"; +import { findTaskWorktree } from "../worktree-paths"; +import { findTaskAndProject } from "./task/shared"; +import { safeSendToRenderer } from "./utils"; /** * Validates status transitions to prevent invalid state changes. @@ -42,8 +48,10 @@ function validateStatusTransition( // Don't allow human_review without subtasks // This prevents tasks from jumping to review before planning is complete - if (newStatus === 'human_review' && (!task.subtasks || task.subtasks.length === 0)) { - console.warn(`[validateStatusTransition] Blocking human_review - task ${task.id} has no subtasks (phase: ${phase})`); + if (newStatus === "human_review" && (!task.subtasks || task.subtasks.length === 0)) { + console.warn( + `[validateStatusTransition] Blocking human_review - task ${task.id} has no subtasks (phase: ${phase})` + ); return false; } @@ -56,14 +64,18 @@ function validateStatusTransition( if (currentPhase && isValidExecutionPhase(currentPhase) && isValidExecutionPhase(phase)) { // Block transitions from terminal phases (complete/failed) if (isTerminalPhase(currentPhase)) { - console.warn(`[validateStatusTransition] Blocking transition from terminal phase: ${currentPhase} for task ${task.id}`); + console.warn( + `[validateStatusTransition] Blocking transition from terminal phase: ${currentPhase} for task ${task.id}` + ); return false; } // Block any phase regression (going backwards in the workflow) // Note: Cast phase to ExecutionPhase since isValidExecutionPhase() type guard doesn't narrow through function calls if (wouldPhaseRegress(currentPhase, phase as ExecutionPhase)) { - console.warn(`[validateStatusTransition] Blocking phase regression: ${currentPhase} -> ${phase} for task ${task.id}`); + console.warn( + `[validateStatusTransition] Blocking phase regression: ${currentPhase} -> ${phase} for task ${task.id}` + ); return false; } @@ -72,12 +84,15 @@ function validateStatusTransition( // e.g., coding starting while planning is still marked as active const newPhase = phase as ExecutionPhase; if (!isValidPhaseTransition(currentPhase, newPhase, completedPhases)) { - console.warn(`[validateStatusTransition] Blocking invalid phase transition: ${currentPhase} -> ${newPhase} for task ${task.id}`, { - currentPhase, - newPhase, - completedPhases, - reason: 'Prerequisite phases not completed' - }); + console.warn( + `[validateStatusTransition] Blocking invalid phase transition: ${currentPhase} -> ${newPhase} for task ${task.id}`, + { + currentPhase, + newPhase, + completedPhases, + reason: "Prerequisite phases not completed", + } + ); return false; } } @@ -85,7 +100,6 @@ function validateStatusTransition( return true; } - /** * Register all agent-events-related IPC handlers */ @@ -97,228 +111,229 @@ export function registerAgenteventsHandlers( // Agent Manager Events → Renderer // ============================================ - agentManager.on('log', (taskId: string, log: string) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - // Include projectId for multi-project filtering (issue #723) - const { project } = findTaskAndProject(taskId); - mainWindow.webContents.send(IPC_CHANNELS.TASK_LOG, taskId, log, project?.id); - } + agentManager.on("log", (taskId: string, log: string) => { + // Include projectId for multi-project filtering (issue #723) + const { project } = findTaskAndProject(taskId); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log, project?.id); }); - agentManager.on('error', (taskId: string, error: string) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - // Include projectId for multi-project filtering (issue #723) - const { project } = findTaskAndProject(taskId); - mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id); - } + agentManager.on("error", (taskId: string, error: string) => { + // Include projectId for multi-project filtering (issue #723) + const { project } = findTaskAndProject(taskId); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id); }); // Handle SDK rate limit events from agent manager - agentManager.on('sdk-rate-limit', (rateLimitInfo: SDKRateLimitInfo) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); - } + agentManager.on("sdk-rate-limit", (rateLimitInfo: SDKRateLimitInfo) => { + safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); }); // Handle SDK rate limit events from title generator - titleGenerator.on('sdk-rate-limit', (rateLimitInfo: SDKRateLimitInfo) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); - } + titleGenerator.on("sdk-rate-limit", (rateLimitInfo: SDKRateLimitInfo) => { + safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); }); - agentManager.on('exit', (taskId: string, code: number | null, processType: ProcessType) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - // Get project info early for multi-project filtering (issue #723) - const { project: exitProject } = findTaskAndProject(taskId); - const exitProjectId = exitProject?.id; - - // Send final plan state to renderer BEFORE unwatching - // This ensures the renderer has the final subtask data (fixes 0/0 subtask bug) - const finalPlan = fileWatcher.getCurrentPlan(taskId); - if (finalPlan) { - mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, finalPlan, exitProjectId); - } + agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType) => { + // Get project info early for multi-project filtering (issue #723) + const { project: exitProject } = findTaskAndProject(taskId); + const exitProjectId = exitProject?.id; + + // Send final plan state to renderer BEFORE unwatching + // This ensures the renderer has the final subtask data (fixes 0/0 subtask bug) + const finalPlan = fileWatcher.getCurrentPlan(taskId); + if (finalPlan) { + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.TASK_PROGRESS, + taskId, + finalPlan, + exitProjectId + ); + } - fileWatcher.unwatch(taskId); + fileWatcher.unwatch(taskId); - if (processType === 'spec-creation') { - console.warn(`[Task ${taskId}] Spec creation completed with code ${code}`); - return; - } + if (processType === "spec-creation") { + console.warn(`[Task ${taskId}] Spec creation completed with code ${code}`); + return; + } - let task: Task | undefined; - let project: Project | undefined; + let task: Task | undefined; + let project: Project | undefined; - try { - const projects = projectStore.getProjects(); + try { + const projects = projectStore.getProjects(); - // IMPORTANT: Invalidate cache for all projects to ensure we get fresh data - // This prevents race conditions where cached task data has stale status - for (const p of projects) { - projectStore.invalidateTasksCache(p.id); - } + // IMPORTANT: Invalidate cache for all projects to ensure we get fresh data + // This prevents race conditions where cached task data has stale status + for (const p of projects) { + projectStore.invalidateTasksCache(p.id); + } - for (const p of projects) { - const tasks = projectStore.getTasks(p.id); - task = tasks.find((t) => t.id === taskId || t.specId === taskId); - if (task) { - project = p; - break; - } + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; } + } - if (task && project) { - const taskTitle = task.title || task.specId; - const mainPlanPath = getPlanPath(project, task); - const projectId = project.id; // Capture for closure - - // Capture task values for closure - const taskSpecId = task.specId; - const projectPath = project.path; - const autoBuildPath = project.autoBuildPath; - - // Use shared utility for persisting status (prevents race conditions) - // Persist to both main project AND worktree (if exists) for consistency - const persistStatus = (status: TaskStatus) => { - // Persist to main project - const mainPersisted = persistPlanStatusSync(mainPlanPath, status, projectId); - if (mainPersisted) { - console.warn(`[Task ${taskId}] Persisted status to main plan: ${status}`); - } + if (task && project) { + const taskTitle = task.title || task.specId; + const mainPlanPath = getPlanPath(project, task); + const projectId = project.id; // Capture for closure + + // Capture task values for closure + const taskSpecId = task.specId; + const projectPath = project.path; + const autoBuildPath = project.autoBuildPath; + + // Use shared utility for persisting status (prevents race conditions) + // Persist to both main project AND worktree (if exists) for consistency + const persistStatus = (status: TaskStatus) => { + // Persist to main project + const mainPersisted = persistPlanStatusSync(mainPlanPath, status, projectId); + if (mainPersisted) { + console.warn(`[Task ${taskId}] Persisted status to main plan: ${status}`); + } - // Also persist to worktree if it exists - const worktreePath = findTaskWorktree(projectPath, taskSpecId); - if (worktreePath) { - const specsBaseDir = getSpecsDir(autoBuildPath); - const worktreePlanPath = path.join( - worktreePath, - specsBaseDir, - taskSpecId, - AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN - ); - if (existsSync(worktreePlanPath)) { - const worktreePersisted = persistPlanStatusSync(worktreePlanPath, status, projectId); - if (worktreePersisted) { - console.warn(`[Task ${taskId}] Persisted status to worktree plan: ${status}`); - } + // Also persist to worktree if it exists + const worktreePath = findTaskWorktree(projectPath, taskSpecId); + if (worktreePath) { + const specsBaseDir = getSpecsDir(autoBuildPath); + const worktreePlanPath = path.join( + worktreePath, + specsBaseDir, + taskSpecId, + AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN + ); + if (existsSync(worktreePlanPath)) { + const worktreePersisted = persistPlanStatusSync(worktreePlanPath, status, projectId); + if (worktreePersisted) { + console.warn(`[Task ${taskId}] Persisted status to worktree plan: ${status}`); } } - }; - - if (code === 0) { - notificationService.notifyReviewNeeded(taskTitle, project.id, taskId); - - // Fallback: Ensure status is updated even if COMPLETE phase event was missed - // This prevents tasks from getting stuck in ai_review status - // FIX (ACS-71): Only move to human_review if subtasks exist AND are all completed - // If no subtasks exist, the task is still in planning and shouldn't move to human_review - const isActiveStatus = task.status === 'in_progress' || task.status === 'ai_review'; - const hasSubtasks = task.subtasks && task.subtasks.length > 0; - const hasIncompleteSubtasks = hasSubtasks && - task.subtasks.some((s) => s.status !== 'completed'); - - if (isActiveStatus && hasSubtasks && !hasIncompleteSubtasks) { - // All subtasks completed - safe to move to human_review - console.warn(`[Task ${taskId}] Fallback: Moving to human_review (process exited successfully, all ${task.subtasks.length} subtasks completed)`); - persistStatus('human_review'); - // Include projectId for multi-project filtering (issue #723) - mainWindow.webContents.send( - IPC_CHANNELS.TASK_STATUS_CHANGE, - taskId, - 'human_review' as TaskStatus, - projectId - ); - } else if (isActiveStatus && !hasSubtasks) { - // No subtasks yet - task is still in planning phase, don't change status - // This prevents the bug where tasks jump to human_review before planning completes - console.warn(`[Task ${taskId}] Process exited but no subtasks created yet - keeping current status (${task.status})`); - } - } else { - notificationService.notifyTaskFailed(taskTitle, project.id, taskId); - persistStatus('human_review'); + } + }; + + if (code === 0) { + notificationService.notifyReviewNeeded(taskTitle, project.id, taskId); + + // Fallback: Ensure status is updated even if COMPLETE phase event was missed + // This prevents tasks from getting stuck in ai_review status + // FIX (ACS-71): Only move to human_review if subtasks exist AND are all completed + // If no subtasks exist, the task is still in planning and shouldn't move to human_review + const isActiveStatus = task.status === "in_progress" || task.status === "ai_review"; + const hasSubtasks = task.subtasks && task.subtasks.length > 0; + const hasIncompleteSubtasks = + hasSubtasks && task.subtasks.some((s) => s.status !== "completed"); + + if (isActiveStatus && hasSubtasks && !hasIncompleteSubtasks) { + // All subtasks completed - safe to move to human_review + console.warn( + `[Task ${taskId}] Fallback: Moving to human_review (process exited successfully, all ${task.subtasks.length} subtasks completed)` + ); + persistStatus("human_review"); // Include projectId for multi-project filtering (issue #723) - mainWindow.webContents.send( + safeSendToRenderer( + getMainWindow, IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, - 'human_review' as TaskStatus, + "human_review" as TaskStatus, projectId ); + } else if (isActiveStatus && !hasSubtasks) { + // No subtasks yet - task is still in planning phase, don't change status + // This prevents the bug where tasks jump to human_review before planning completes + console.warn( + `[Task ${taskId}] Process exited but no subtasks created yet - keeping current status (${task.status})` + ); } + } else { + notificationService.notifyTaskFailed(taskTitle, project.id, taskId); + persistStatus("human_review"); + // Include projectId for multi-project filtering (issue #723) + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + "human_review" as TaskStatus, + projectId + ); } - } catch (error) { - console.error(`[Task ${taskId}] Exit handler error:`, error); } + } catch (error) { + console.error(`[Task ${taskId}] Exit handler error:`, error); } }); - agentManager.on('execution-progress', (taskId: string, progress: ExecutionProgressData) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - // Use shared helper to find task and project (issue #723 - deduplicate lookup) - const { task, project } = findTaskAndProject(taskId); - const taskProjectId = project?.id; - - // Include projectId in execution progress event for multi-project filtering - mainWindow.webContents.send(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, taskId, progress, taskProjectId); - - const phaseToStatus: Record = { - 'idle': null, - 'planning': 'in_progress', - 'coding': 'in_progress', - 'qa_review': 'ai_review', - 'qa_fixing': 'ai_review', - 'complete': 'human_review', - 'failed': 'human_review' - }; - - const newStatus = phaseToStatus[progress.phase]; - // FIX (ACS-55, ACS-71): Validate status transition before sending/persisting - if (newStatus && validateStatusTransition(task, newStatus, progress.phase)) { - // Include projectId in status change event for multi-project filtering - mainWindow.webContents.send( - IPC_CHANNELS.TASK_STATUS_CHANGE, - taskId, - newStatus, - taskProjectId - ); - - // CRITICAL: Persist status to plan file(s) to prevent flip-flop on task list refresh - // When getTasks() is called, it reads status from the plan file. Without persisting, - // the status in the file might differ from the UI, causing inconsistent state. - // Uses shared utility with locking to prevent race conditions. - // IMPORTANT: We persist to BOTH main project AND worktree (if exists) to ensure - // consistency, since getTasks() prefers the worktree version. - if (task && project) { - try { - // Persist to main project plan file - const mainPlanPath = getPlanPath(project, task); - persistPlanStatusSync(mainPlanPath, newStatus, project.id); - - // Also persist to worktree plan file if it exists - // This ensures consistency since getTasks() prefers worktree version - const worktreePath = findTaskWorktree(project.path, task.specId); - if (worktreePath) { - const specsBaseDir = getSpecsDir(project.autoBuildPath); - const worktreePlanPath = path.join( - worktreePath, - specsBaseDir, - task.specId, - AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN - ); - if (existsSync(worktreePlanPath)) { - persistPlanStatusSync(worktreePlanPath, newStatus, project.id); - } + agentManager.on("execution-progress", (taskId: string, progress: ExecutionProgressData) => { + // Use shared helper to find task and project (issue #723 - deduplicate lookup) + const { task, project } = findTaskAndProject(taskId); + const taskProjectId = project?.id; + + // Include projectId in execution progress event for multi-project filtering + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.TASK_EXECUTION_PROGRESS, + taskId, + progress, + taskProjectId + ); + + const phaseToStatus: Record = { + idle: null, + planning: "in_progress", + coding: "in_progress", + qa_review: "ai_review", + qa_fixing: "ai_review", + complete: "human_review", + failed: "human_review", + }; + + const newStatus = phaseToStatus[progress.phase]; + // FIX (ACS-55, ACS-71): Validate status transition before sending/persisting + if (newStatus && validateStatusTransition(task, newStatus, progress.phase)) { + // Include projectId in status change event for multi-project filtering + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + newStatus, + taskProjectId + ); + + // CRITICAL: Persist status to plan file(s) to prevent flip-flop on task list refresh + // When getTasks() is called, it reads status from the plan file. Without persisting, + // the status in the file might differ from the UI, causing inconsistent state. + // Uses shared utility with locking to prevent race conditions. + // IMPORTANT: We persist to BOTH main project AND worktree (if exists) to ensure + // consistency, since getTasks() prefers the worktree version. + if (task && project) { + try { + // Persist to main project plan file + const mainPlanPath = getPlanPath(project, task); + persistPlanStatusSync(mainPlanPath, newStatus, project.id); + + // Also persist to worktree plan file if it exists + // This ensures consistency since getTasks() prefers worktree version + const worktreePath = findTaskWorktree(project.path, task.specId); + if (worktreePath) { + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const worktreePlanPath = path.join( + worktreePath, + specsBaseDir, + task.specId, + AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN + ); + if (existsSync(worktreePlanPath)) { + persistPlanStatusSync(worktreePlanPath, newStatus, project.id); } - } catch (err) { - // Ignore persistence errors - UI will still work, just might flip on refresh - console.warn('[execution-progress] Could not persist status:', err); } + } catch (err) { + // Ignore persistence errors - UI will still work, just might flip on refresh + console.warn("[execution-progress] Could not persist status:", err); } } } @@ -328,21 +343,15 @@ export function registerAgenteventsHandlers( // File Watcher Events → Renderer // ============================================ - fileWatcher.on('progress', (taskId: string, plan: ImplementationPlan) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - // Use shared helper to find project (issue #723 - deduplicate lookup) - const { project } = findTaskAndProject(taskId); - mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, plan, project?.id); - } + fileWatcher.on("progress", (taskId: string, plan: ImplementationPlan) => { + // Use shared helper to find project (issue #723 - deduplicate lookup) + const { project } = findTaskAndProject(taskId); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_PROGRESS, taskId, plan, project?.id); }); - fileWatcher.on('error', (taskId: string, error: string) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - // Include projectId for multi-project filtering (issue #723) - const { project } = findTaskAndProject(taskId); - mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id); - } + fileWatcher.on("error", (taskId: string, error: string) => { + // Include projectId for multi-project filtering (issue #723) + const { project } = findTaskAndProject(taskId); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id); }); } diff --git a/apps/frontend/src/main/ipc-handlers/ideation-handlers.ts b/apps/frontend/src/main/ipc-handlers/ideation-handlers.ts index a5097f30c3..e169f71400 100644 --- a/apps/frontend/src/main/ipc-handlers/ideation-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/ideation-handlers.ts @@ -12,11 +12,11 @@ * - file-utils.ts: File system operations */ -import { ipcMain } from 'electron'; -import type { BrowserWindow } from 'electron'; -import { IPC_CHANNELS } from '../../shared/constants'; -import type { AgentManager } from '../agent'; -import type { IdeationGenerationStatus, IdeationSession, Idea } from '../../shared/types'; +import { ipcMain } from "electron"; +import type { BrowserWindow } from "electron"; +import { IPC_CHANNELS } from "../../shared/constants"; +import type { AgentManager } from "../agent"; +import type { IdeationGenerationStatus, IdeationSession, Idea } from "../../shared/types"; import { getIdeationSession, updateIdeaStatus, @@ -28,8 +28,9 @@ import { startIdeationGeneration, refreshIdeationSession, stopIdeationGeneration, - convertIdeaToTask -} from './ideation'; + convertIdeaToTask, +} from "./ideation"; +import { safeSendToRenderer } from "./utils"; /** * Register all ideation-related IPC handlers @@ -39,135 +40,94 @@ export function registerIdeationHandlers( getMainWindow: () => BrowserWindow | null ): () => void { // Session management - ipcMain.handle( - IPC_CHANNELS.IDEATION_GET, - getIdeationSession - ); + ipcMain.handle(IPC_CHANNELS.IDEATION_GET, getIdeationSession); // Idea operations - ipcMain.handle( - IPC_CHANNELS.IDEATION_UPDATE_IDEA, - updateIdeaStatus - ); + ipcMain.handle(IPC_CHANNELS.IDEATION_UPDATE_IDEA, updateIdeaStatus); - ipcMain.handle( - IPC_CHANNELS.IDEATION_DISMISS, - dismissIdea - ); + ipcMain.handle(IPC_CHANNELS.IDEATION_DISMISS, dismissIdea); - ipcMain.handle( - IPC_CHANNELS.IDEATION_DISMISS_ALL, - dismissAllIdeas - ); + ipcMain.handle(IPC_CHANNELS.IDEATION_DISMISS_ALL, dismissAllIdeas); - ipcMain.handle( - IPC_CHANNELS.IDEATION_ARCHIVE, - archiveIdea - ); + ipcMain.handle(IPC_CHANNELS.IDEATION_ARCHIVE, archiveIdea); - ipcMain.handle( - IPC_CHANNELS.IDEATION_DELETE, - deleteIdea - ); + ipcMain.handle(IPC_CHANNELS.IDEATION_DELETE, deleteIdea); - ipcMain.handle( - IPC_CHANNELS.IDEATION_DELETE_MULTIPLE, - deleteMultipleIdeas - ); + ipcMain.handle(IPC_CHANNELS.IDEATION_DELETE_MULTIPLE, deleteMultipleIdeas); // Generation operations - ipcMain.on( - IPC_CHANNELS.IDEATION_GENERATE, - (event, projectId, config) => - startIdeationGeneration(event, projectId, config, agentManager, getMainWindow()) + ipcMain.on(IPC_CHANNELS.IDEATION_GENERATE, (event, projectId, config) => + startIdeationGeneration(event, projectId, config, agentManager, getMainWindow()) ); - ipcMain.on( - IPC_CHANNELS.IDEATION_REFRESH, - (event, projectId, config) => - refreshIdeationSession(event, projectId, config, agentManager, getMainWindow()) + ipcMain.on(IPC_CHANNELS.IDEATION_REFRESH, (event, projectId, config) => + refreshIdeationSession(event, projectId, config, agentManager, getMainWindow()) ); - ipcMain.handle( - IPC_CHANNELS.IDEATION_STOP, - (event, projectId) => - stopIdeationGeneration(event, projectId, agentManager, getMainWindow()) + ipcMain.handle(IPC_CHANNELS.IDEATION_STOP, (event, projectId) => + stopIdeationGeneration(event, projectId, agentManager, getMainWindow()) ); // Task conversion - ipcMain.handle( - IPC_CHANNELS.IDEATION_CONVERT_TO_TASK, - convertIdeaToTask - ); + ipcMain.handle(IPC_CHANNELS.IDEATION_CONVERT_TO_TASK, convertIdeaToTask); // ============================================ // Ideation Agent Events → Renderer // ============================================ const handleIdeationProgress = (projectId: string, status: IdeationGenerationStatus): void => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.IDEATION_PROGRESS, projectId, status); - } + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_PROGRESS, projectId, status); }; const handleIdeationLog = (projectId: string, log: string): void => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.IDEATION_LOG, projectId, log); - } + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_LOG, projectId, log); }; - const handleIdeationTypeComplete = (projectId: string, ideationType: string, ideas: Idea[]): void => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, projectId, ideationType, ideas); - } + const handleIdeationTypeComplete = ( + projectId: string, + ideationType: string, + ideas: Idea[] + ): void => { + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.IDEATION_TYPE_COMPLETE, + projectId, + ideationType, + ideas + ); }; const handleIdeationTypeFailed = (projectId: string, ideationType: string): void => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.IDEATION_TYPE_FAILED, projectId, ideationType); - } + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_TYPE_FAILED, projectId, ideationType); }; const handleIdeationComplete = (projectId: string, session: IdeationSession): void => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.IDEATION_COMPLETE, projectId, session); - } + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_COMPLETE, projectId, session); }; const handleIdeationError = (projectId: string, error: string): void => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.IDEATION_ERROR, projectId, error); - } + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_ERROR, projectId, error); }; const handleIdeationStopped = (projectId: string): void => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId); - } + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_STOPPED, projectId); }; - agentManager.on('ideation-progress', handleIdeationProgress); - agentManager.on('ideation-log', handleIdeationLog); - agentManager.on('ideation-type-complete', handleIdeationTypeComplete); - agentManager.on('ideation-type-failed', handleIdeationTypeFailed); - agentManager.on('ideation-complete', handleIdeationComplete); - agentManager.on('ideation-error', handleIdeationError); - agentManager.on('ideation-stopped', handleIdeationStopped); + agentManager.on("ideation-progress", handleIdeationProgress); + agentManager.on("ideation-log", handleIdeationLog); + agentManager.on("ideation-type-complete", handleIdeationTypeComplete); + agentManager.on("ideation-type-failed", handleIdeationTypeFailed); + agentManager.on("ideation-complete", handleIdeationComplete); + agentManager.on("ideation-error", handleIdeationError); + agentManager.on("ideation-stopped", handleIdeationStopped); return (): void => { - agentManager.off('ideation-progress', handleIdeationProgress); - agentManager.off('ideation-log', handleIdeationLog); - agentManager.off('ideation-type-complete', handleIdeationTypeComplete); - agentManager.off('ideation-type-failed', handleIdeationTypeFailed); - agentManager.off('ideation-complete', handleIdeationComplete); - agentManager.off('ideation-error', handleIdeationError); - agentManager.off('ideation-stopped', handleIdeationStopped); + agentManager.off("ideation-progress", handleIdeationProgress); + agentManager.off("ideation-log", handleIdeationLog); + agentManager.off("ideation-type-complete", handleIdeationTypeComplete); + agentManager.off("ideation-type-failed", handleIdeationTypeFailed); + agentManager.off("ideation-complete", handleIdeationComplete); + agentManager.off("ideation-error", handleIdeationError); + agentManager.off("ideation-stopped", handleIdeationStopped); }; } diff --git a/apps/frontend/src/main/ipc-handlers/ideation/generation-handlers.ts b/apps/frontend/src/main/ipc-handlers/ideation/generation-handlers.ts index 6077c46cd7..1694f40ca9 100644 --- a/apps/frontend/src/main/ipc-handlers/ideation/generation-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/ideation/generation-handlers.ts @@ -2,25 +2,36 @@ * Ideation generation handlers (start/stop generation) */ -import type { IpcMainEvent, IpcMainInvokeEvent, BrowserWindow } from 'electron'; -import { app } from 'electron'; -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; -import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../../shared/constants'; -import type { IPCResult, IdeationConfig, IdeationGenerationStatus, AppSettings } from '../../../shared/types'; -import { projectStore } from '../../project-store'; -import type { AgentManager } from '../../agent'; -import { debugLog, debugError } from '../../../shared/utils/debug-logger'; +import type { IpcMainEvent, IpcMainInvokeEvent, BrowserWindow } from "electron"; +import { app } from "electron"; +import { existsSync, readFileSync } from "fs"; +import path from "path"; +import { + IPC_CHANNELS, + DEFAULT_APP_SETTINGS, + DEFAULT_FEATURE_MODELS, + DEFAULT_FEATURE_THINKING, +} from "../../../shared/constants"; +import type { + IPCResult, + IdeationConfig, + IdeationGenerationStatus, + AppSettings, +} from "../../../shared/types"; +import { projectStore } from "../../project-store"; +import type { AgentManager } from "../../agent"; +import { debugLog, debugError } from "../../../shared/utils/debug-logger"; +import { safeSendToRenderer } from "../utils"; /** * Read ideation feature settings from the settings file */ function getIdeationFeatureSettings(): { model?: string; thinkingLevel?: string } { - const settingsPath = path.join(app.getPath('userData'), 'settings.json'); + const settingsPath = path.join(app.getPath("userData"), "settings.json"); try { if (existsSync(settingsPath)) { - const content = readFileSync(settingsPath, 'utf-8'); + const content = readFileSync(settingsPath, "utf-8"); const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) }; // Get ideation-specific settings @@ -29,17 +40,17 @@ function getIdeationFeatureSettings(): { model?: string; thinkingLevel?: string return { model: featureModels.ideation, - thinkingLevel: featureThinking.ideation + thinkingLevel: featureThinking.ideation, }; } } catch (error) { - debugError('[Ideation Handler] Failed to read feature settings:', error); + debugError("[Ideation Handler] Failed to read feature settings:", error); } // Return defaults if settings file doesn't exist or fails to parse return { model: DEFAULT_FEATURE_MODELS.ideation, - thinkingLevel: DEFAULT_FEATURE_THINKING.ideation + thinkingLevel: DEFAULT_FEATURE_THINKING.ideation, }; } @@ -58,50 +69,42 @@ export function startIdeationGeneration( const configWithSettings: IdeationConfig = { ...config, model: config.model || featureSettings.model, - thinkingLevel: config.thinkingLevel || featureSettings.thinkingLevel + thinkingLevel: config.thinkingLevel || featureSettings.thinkingLevel, }; - debugLog('[Ideation Handler] Start generation request:', { + debugLog("[Ideation Handler] Start generation request:", { projectId, enabledTypes: configWithSettings.enabledTypes, maxIdeasPerType: configWithSettings.maxIdeasPerType, model: configWithSettings.model, - thinkingLevel: configWithSettings.thinkingLevel + thinkingLevel: configWithSettings.thinkingLevel, }); - if (!mainWindow) return; + const getMainWindow = () => mainWindow; const project = projectStore.getProject(projectId); if (!project) { - debugLog('[Ideation Handler] Project not found:', projectId); - mainWindow.webContents.send( - IPC_CHANNELS.IDEATION_ERROR, - projectId, - 'Project not found' - ); + debugLog("[Ideation Handler] Project not found:", projectId); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_ERROR, projectId, "Project not found"); return; } - debugLog('[Ideation Handler] Starting agent manager generation:', { + debugLog("[Ideation Handler] Starting agent manager generation:", { projectId, projectPath: project.path, model: configWithSettings.model, - thinkingLevel: configWithSettings.thinkingLevel + thinkingLevel: configWithSettings.thinkingLevel, }); // Start ideation generation via agent manager agentManager.startIdeationGeneration(projectId, project.path, configWithSettings, false); // Send initial progress - mainWindow.webContents.send( - IPC_CHANNELS.IDEATION_PROGRESS, - projectId, - { - phase: 'analyzing', - progress: 10, - message: 'Analyzing project structure...' - } as IdeationGenerationStatus - ); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_PROGRESS, projectId, { + phase: "analyzing", + progress: 10, + message: "Analyzing project structure...", + } as IdeationGenerationStatus); } /** @@ -119,24 +122,20 @@ export function refreshIdeationSession( const configWithSettings: IdeationConfig = { ...config, model: config.model || featureSettings.model, - thinkingLevel: config.thinkingLevel || featureSettings.thinkingLevel + thinkingLevel: config.thinkingLevel || featureSettings.thinkingLevel, }; - debugLog('[Ideation Handler] Refresh session request:', { + debugLog("[Ideation Handler] Refresh session request:", { projectId, model: configWithSettings.model, - thinkingLevel: configWithSettings.thinkingLevel + thinkingLevel: configWithSettings.thinkingLevel, }); - if (!mainWindow) return; + const getMainWindow = () => mainWindow; const project = projectStore.getProject(projectId); if (!project) { - mainWindow.webContents.send( - IPC_CHANNELS.IDEATION_ERROR, - projectId, - 'Project not found' - ); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_ERROR, projectId, "Project not found"); return; } @@ -144,15 +143,11 @@ export function refreshIdeationSession( agentManager.startIdeationGeneration(projectId, project.path, configWithSettings, true); // Send initial progress - mainWindow.webContents.send( - IPC_CHANNELS.IDEATION_PROGRESS, - projectId, - { - phase: 'analyzing', - progress: 10, - message: 'Refreshing ideation...' - } as IdeationGenerationStatus - ); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_PROGRESS, projectId, { + phase: "analyzing", + progress: 10, + message: "Refreshing ideation...", + } as IdeationGenerationStatus); } /** @@ -164,15 +159,16 @@ export async function stopIdeationGeneration( agentManager: AgentManager, mainWindow: BrowserWindow | null ): Promise { - debugLog('[Ideation Handler] Stop generation request:', { projectId }); + debugLog("[Ideation Handler] Stop generation request:", { projectId }); const wasStopped = agentManager.stopIdeation(projectId); - debugLog('[Ideation Handler] Stop result:', { projectId, wasStopped }); + debugLog("[Ideation Handler] Stop result:", { projectId, wasStopped }); - if (wasStopped && mainWindow) { - debugLog('[Ideation Handler] Sending stopped event to renderer'); - mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId); + if (wasStopped) { + debugLog("[Ideation Handler] Sending stopped event to renderer"); + const getMainWindow = () => mainWindow; + safeSendToRenderer(getMainWindow, IPC_CHANNELS.IDEATION_STOPPED, projectId); } return { success: wasStopped }; diff --git a/apps/frontend/src/main/ipc-handlers/insights-handlers.ts b/apps/frontend/src/main/ipc-handlers/insights-handlers.ts index 11a18c0b88..ab3a6b8904 100644 --- a/apps/frontend/src/main/ipc-handlers/insights-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/insights-handlers.ts @@ -1,18 +1,24 @@ -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'; +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"; +import { safeSendToRenderer } from "./utils"; /** * Register all insights-related IPC handlers */ -export function registerInsightsHandlers( - getMainWindow: () => BrowserWindow | null -): void { +export function registerInsightsHandlers(getMainWindow: () => BrowserWindow | null): void { // ============================================ // Insights Operations // ============================================ @@ -22,7 +28,7 @@ export function registerInsightsHandlers( async (_, projectId: string): Promise> => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } const session = insightsService.loadSession(projectId, project.path); @@ -35,10 +41,12 @@ export function registerInsightsHandlers( 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'); - } + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.INSIGHTS_ERROR, + projectId, + "Project not found" + ); return; } @@ -52,16 +60,14 @@ export function registerInsightsHandlers( // 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}` - ); - } + console.error("[Insights IPC] Error in sendMessage:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.INSIGHTS_ERROR, + projectId, + `Failed to send message: ${errorMessage}` + ); } } ); @@ -71,7 +77,7 @@ export function registerInsightsHandlers( async (_, projectId: string): Promise => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } insightsService.clearSession(projectId, project.path); @@ -90,32 +96,32 @@ export function registerInsightsHandlers( ): Promise> => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } if (!project.autoBuildPath) { - return { success: false, error: 'Auto Claude not initialized for this project' }; + 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 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); + .filter((d) => d.isDirectory()) + .map((d) => d.name); const existingNumbers = existingDirs - .map(name => { + .map((name) => { const match = name.match(/^(\d+)/); return match ? parseInt(match[1], 10) : 0; }) - .filter(n => n > 0); + .filter((n) => n > 0); if (existingNumbers.length > 0) { specNumber = Math.max(...existingNumbers) + 1; @@ -125,10 +131,10 @@ export function registerInsightsHandlers( // Create spec ID with zero-padded number and slugified title const slugifiedTitle = title .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") .substring(0, 50); - const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + const specId = `${String(specNumber).padStart(3, "0")}-${slugifiedTitle}`; // Create spec directory const specDir = path.join(specsDir, specId); @@ -136,8 +142,8 @@ export function registerInsightsHandlers( // Build metadata with source type const taskMetadata: TaskMetadata = { - sourceType: 'insights', - ...metadata + sourceType: "insights", + ...metadata, }; // Create initial implementation_plan.json @@ -147,15 +153,15 @@ export function registerInsightsHandlers( description: description, created_at: now, updated_at: now, - status: 'pending', - phases: [] + 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'); + const metadataPath = path.join(specDir, "task_metadata.json"); writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2)); // Create the task object @@ -165,19 +171,19 @@ export function registerInsightsHandlers( projectId, title, description, - status: 'backlog', + status: "backlog", subtasks: [], logs: [], metadata: taskMetadata, createdAt: new Date(), - updatedAt: 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' + error: error instanceof Error ? error.message : "Failed to create task", }; } } @@ -189,7 +195,7 @@ export function registerInsightsHandlers( async (_, projectId: string): Promise> => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } const sessions = insightsService.listSessions(project.path); @@ -203,7 +209,7 @@ export function registerInsightsHandlers( async (_, projectId: string): Promise> => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } const session = insightsService.createNewSession(projectId, project.path); @@ -217,7 +223,7 @@ export function registerInsightsHandlers( async (_, projectId: string, sessionId: string): Promise> => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } const session = insightsService.switchSession(projectId, project.path, sessionId); @@ -231,14 +237,14 @@ export function registerInsightsHandlers( async (_, projectId: string, sessionId: string): Promise => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + 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' }; + return { success: false, error: "Failed to delete session" }; } ); @@ -248,31 +254,40 @@ export function registerInsightsHandlers( async (_, projectId: string, sessionId: string, newTitle: string): Promise => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + 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' }; + 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 => { + async ( + _, + projectId: string, + sessionId: string, + modelConfig: InsightsModelConfig + ): Promise => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } - const success = insightsService.updateSessionModelConfig(project.path, sessionId, modelConfig); + const success = insightsService.updateSessionModelConfig( + project.path, + sessionId, + modelConfig + ); if (success) { return { success: true }; } - return { success: false, error: 'Failed to update model configuration' }; + return { success: false, error: "Failed to update model configuration" }; } ); @@ -281,35 +296,22 @@ export function registerInsightsHandlers( // ============================================ // 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); - } + insightsService.on("stream-chunk", (projectId: string, chunk: unknown) => { + safeSendToRenderer(getMainWindow, 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); - } + insightsService.on("status", (projectId: string, status: unknown) => { + safeSendToRenderer(getMainWindow, 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); - } + insightsService.on("error", (projectId: string, error: string) => { + safeSendToRenderer(getMainWindow, 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); - } + insightsService.on("sdk-rate-limit", (rateLimitInfo: unknown) => { + safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); }); - } diff --git a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts index e963b0a87f..2a4ae1ce0c 100644 --- a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts @@ -1,23 +1,41 @@ -import { ipcMain, app } from 'electron'; -import type { BrowserWindow } from 'electron'; -import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../shared/constants'; -import type { IPCResult, Roadmap, RoadmapFeature, RoadmapFeatureStatus, RoadmapGenerationStatus, Task, TaskMetadata, CompetitorAnalysis, AppSettings } from '../../shared/types'; -import type { RoadmapConfig } from '../agent/types'; -import path from 'path'; -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'; -import { projectStore } from '../project-store'; -import { AgentManager } from '../agent'; -import { debugLog, debugError } from '../../shared/utils/debug-logger'; +import { ipcMain, app } from "electron"; +import type { BrowserWindow } from "electron"; +import { + IPC_CHANNELS, + AUTO_BUILD_PATHS, + getSpecsDir, + DEFAULT_APP_SETTINGS, + DEFAULT_FEATURE_MODELS, + DEFAULT_FEATURE_THINKING, +} from "../../shared/constants"; +import type { + IPCResult, + Roadmap, + RoadmapFeature, + RoadmapFeatureStatus, + RoadmapGenerationStatus, + Task, + TaskMetadata, + CompetitorAnalysis, + AppSettings, +} from "../../shared/types"; +import type { RoadmapConfig } from "../agent/types"; +import path from "path"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs"; +import { projectStore } from "../project-store"; +import { AgentManager } from "../agent"; +import { debugLog, debugError } from "../../shared/utils/debug-logger"; +import { safeSendToRenderer } from "./utils"; /** * Read feature settings from the settings file */ function getFeatureSettings(): { model?: string; thinkingLevel?: string } { - const settingsPath = path.join(app.getPath('userData'), 'settings.json'); + const settingsPath = path.join(app.getPath("userData"), "settings.json"); try { if (existsSync(settingsPath)) { - const content = readFileSync(settingsPath, 'utf-8'); + const content = readFileSync(settingsPath, "utf-8"); const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) }; // Get roadmap-specific settings @@ -26,21 +44,20 @@ function getFeatureSettings(): { model?: string; thinkingLevel?: string } { return { model: featureModels.roadmap, - thinkingLevel: featureThinking.roadmap + thinkingLevel: featureThinking.roadmap, }; } } catch (error) { - debugError('[Roadmap Handler] Failed to read feature settings:', error); + debugError("[Roadmap Handler] Failed to read feature settings:", error); } // Return defaults if settings file doesn't exist or fails to parse return { model: DEFAULT_FEATURE_MODELS.roadmap, - thinkingLevel: DEFAULT_FEATURE_THINKING.roadmap + thinkingLevel: DEFAULT_FEATURE_THINKING.roadmap, }; } - /** * Register all roadmap-related IPC handlers */ @@ -57,7 +74,7 @@ export function registerRoadmapHandlers( async (_, projectId: string): Promise> => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } const roadmapPath = path.join( @@ -71,7 +88,7 @@ export function registerRoadmapHandlers( } try { - const content = readFileSync(roadmapPath, 'utf-8'); + const content = readFileSync(roadmapPath, "utf-8"); const rawRoadmap = JSON.parse(content); // Load competitor analysis if available (competitor_analysis.json) @@ -83,50 +100,53 @@ export function registerRoadmapHandlers( let competitorAnalysis: CompetitorAnalysis | undefined; if (existsSync(competitorAnalysisPath)) { try { - const competitorContent = readFileSync(competitorAnalysisPath, 'utf-8'); + const competitorContent = readFileSync(competitorAnalysisPath, "utf-8"); const rawCompetitor = JSON.parse(competitorContent); // Transform snake_case to camelCase for frontend competitorAnalysis = { projectContext: { - projectName: rawCompetitor.project_context?.project_name || '', - projectType: rawCompetitor.project_context?.project_type || '', - targetAudience: rawCompetitor.project_context?.target_audience || '' + projectName: rawCompetitor.project_context?.project_name || "", + projectType: rawCompetitor.project_context?.project_type || "", + targetAudience: rawCompetitor.project_context?.target_audience || "", }, competitors: (rawCompetitor.competitors || []).map((c: Record) => ({ id: c.id, name: c.name, url: c.url, description: c.description, - relevance: c.relevance || 'medium', + relevance: c.relevance || "medium", painPoints: ((c.pain_points as Array>) || []).map((p) => ({ id: p.id, description: p.description, source: p.source, - severity: p.severity || 'medium', - frequency: p.frequency || '', - opportunity: p.opportunity || '' + severity: p.severity || "medium", + frequency: p.frequency || "", + opportunity: p.opportunity || "", })), strengths: (c.strengths as string[]) || [], - marketPosition: (c.market_position as string) || '' + marketPosition: (c.market_position as string) || "", })), marketGaps: (rawCompetitor.market_gaps || []).map((g: Record) => ({ id: g.id, description: g.description, affectedCompetitors: (g.affected_competitors as string[]) || [], - opportunitySize: g.opportunity_size || 'medium', - suggestedFeature: (g.suggested_feature as string) || '' + opportunitySize: g.opportunity_size || "medium", + suggestedFeature: (g.suggested_feature as string) || "", })), insightsSummary: { topPainPoints: rawCompetitor.insights_summary?.top_pain_points || [], - differentiatorOpportunities: rawCompetitor.insights_summary?.differentiator_opportunities || [], - marketTrends: rawCompetitor.insights_summary?.market_trends || [] + differentiatorOpportunities: + rawCompetitor.insights_summary?.differentiator_opportunities || [], + marketTrends: rawCompetitor.insights_summary?.market_trends || [], }, researchMetadata: { searchQueriesUsed: rawCompetitor.research_metadata?.search_queries_used || [], sourcesConsulted: rawCompetitor.research_metadata?.sources_consulted || [], - limitations: rawCompetitor.research_metadata?.limitations || [] + limitations: rawCompetitor.research_metadata?.limitations || [], }, - createdAt: rawCompetitor.metadata?.created_at ? new Date(rawCompetitor.metadata.created_at) : new Date() + createdAt: rawCompetitor.metadata?.created_at + ? new Date(rawCompetitor.metadata.created_at) + : new Date(), }; } catch { // Ignore competitor analysis parsing errors - it's optional @@ -138,55 +158,59 @@ export function registerRoadmapHandlers( id: rawRoadmap.id || `roadmap-${Date.now()}`, projectId, projectName: rawRoadmap.project_name || project.name, - version: rawRoadmap.version || '1.0', - vision: rawRoadmap.vision || '', + version: rawRoadmap.version || "1.0", + vision: rawRoadmap.vision || "", targetAudience: { - primary: rawRoadmap.target_audience?.primary || '', - secondary: rawRoadmap.target_audience?.secondary || [] + primary: rawRoadmap.target_audience?.primary || "", + secondary: rawRoadmap.target_audience?.secondary || [], }, phases: (rawRoadmap.phases || []).map((phase: Record) => ({ id: phase.id, name: phase.name, description: phase.description, order: phase.order, - status: phase.status || 'planned', + status: phase.status || "planned", features: phase.features || [], - milestones: (phase.milestones as Array> || []).map((m) => ({ + milestones: ((phase.milestones as Array>) || []).map((m) => ({ id: m.id, title: m.title, description: m.description, features: m.features || [], - status: m.status || 'planned', - targetDate: m.target_date ? new Date(m.target_date as string) : undefined - })) + status: m.status || "planned", + targetDate: m.target_date ? new Date(m.target_date as string) : undefined, + })), })), features: (rawRoadmap.features || []).map((feature: Record) => ({ id: feature.id, title: feature.title, description: feature.description, - rationale: feature.rationale || '', - priority: feature.priority || 'should', - complexity: feature.complexity || 'medium', - impact: feature.impact || 'medium', + rationale: feature.rationale || "", + priority: feature.priority || "should", + complexity: feature.complexity || "medium", + impact: feature.impact || "medium", phaseId: feature.phase_id, dependencies: feature.dependencies || [], - status: feature.status || 'under_review', + status: feature.status || "under_review", acceptanceCriteria: feature.acceptance_criteria || [], userStories: feature.user_stories || [], linkedSpecId: feature.linked_spec_id, - competitorInsightIds: (feature.competitor_insight_ids as string[]) || undefined + competitorInsightIds: (feature.competitor_insight_ids as string[]) || undefined, })), - status: rawRoadmap.status || 'draft', + status: rawRoadmap.status || "draft", competitorAnalysis, - createdAt: rawRoadmap.metadata?.created_at ? new Date(rawRoadmap.metadata.created_at) : new Date(), - updatedAt: rawRoadmap.metadata?.updated_at ? new Date(rawRoadmap.metadata.updated_at) : new Date() + createdAt: rawRoadmap.metadata?.created_at + ? new Date(rawRoadmap.metadata.created_at) + : new Date(), + updatedAt: rawRoadmap.metadata?.updated_at + ? new Date(rawRoadmap.metadata.updated_at) + : new Date(), }; return { success: true, data: roadmap }; } catch (error) { return { success: false, - error: error instanceof Error ? error.message : 'Failed to read roadmap' + error: error instanceof Error ? error.message : "Failed to read roadmap", }; } } @@ -197,26 +221,31 @@ export function registerRoadmapHandlers( IPC_CHANNELS.ROADMAP_GET_STATUS, async (_, projectId: string): Promise> => { const isRunning = agentManager.isRoadmapRunning(projectId); - debugLog('[Roadmap Handler] Get status:', { projectId, isRunning }); + debugLog("[Roadmap Handler] Get status:", { projectId, isRunning }); return { success: true, data: { isRunning } }; } ); ipcMain.on( IPC_CHANNELS.ROADMAP_GENERATE, - (_, projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => { + ( + _, + projectId: string, + enableCompetitorAnalysis?: boolean, + refreshCompetitorAnalysis?: boolean + ) => { // Get feature settings for roadmap const featureSettings = getFeatureSettings(); const config: RoadmapConfig = { model: featureSettings.model, - thinkingLevel: featureSettings.thinkingLevel + thinkingLevel: featureSettings.thinkingLevel, }; - debugLog('[Roadmap Handler] Generate request:', { + debugLog("[Roadmap Handler] Generate request:", { projectId, enableCompetitorAnalysis, refreshCompetitorAnalysis, - config + config, }); const mainWindow = getMainWindow(); @@ -224,19 +253,20 @@ export function registerRoadmapHandlers( const project = projectStore.getProject(projectId); if (!project) { - debugError('[Roadmap Handler] Project not found:', projectId); - mainWindow.webContents.send( + debugError("[Roadmap Handler] Project not found:", projectId); + safeSendToRenderer( + getMainWindow, IPC_CHANNELS.ROADMAP_ERROR, projectId, - 'Project not found' + "Project not found" ); return; } - debugLog('[Roadmap Handler] Starting agent manager generation:', { + debugLog("[Roadmap Handler] Starting agent manager generation:", { projectId, projectPath: project.path, - config + config, }); // Start roadmap generation via agent manager @@ -250,33 +280,34 @@ export function registerRoadmapHandlers( ); // Send initial progress - mainWindow.webContents.send( - IPC_CHANNELS.ROADMAP_PROGRESS, - projectId, - { - phase: 'analyzing', - progress: 10, - message: 'Analyzing project structure...' - } as RoadmapGenerationStatus - ); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.ROADMAP_PROGRESS, projectId, { + phase: "analyzing", + progress: 10, + message: "Analyzing project structure...", + } as RoadmapGenerationStatus); } ); ipcMain.on( IPC_CHANNELS.ROADMAP_REFRESH, - (_, projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => { + ( + _, + projectId: string, + enableCompetitorAnalysis?: boolean, + refreshCompetitorAnalysis?: boolean + ) => { // Get feature settings for roadmap const featureSettings = getFeatureSettings(); const config: RoadmapConfig = { model: featureSettings.model, - thinkingLevel: featureSettings.thinkingLevel + thinkingLevel: featureSettings.thinkingLevel, }; - debugLog('[Roadmap Handler] Refresh request:', { + debugLog("[Roadmap Handler] Refresh request:", { projectId, enableCompetitorAnalysis, refreshCompetitorAnalysis, - config + config, }); const mainWindow = getMainWindow(); @@ -284,10 +315,11 @@ export function registerRoadmapHandlers( const project = projectStore.getProject(projectId); if (!project) { - mainWindow.webContents.send( + safeSendToRenderer( + getMainWindow, IPC_CHANNELS.ROADMAP_ERROR, projectId, - 'Project not found' + "Project not found" ); return; } @@ -303,38 +335,29 @@ export function registerRoadmapHandlers( ); // Send initial progress - mainWindow.webContents.send( - IPC_CHANNELS.ROADMAP_PROGRESS, - projectId, - { - phase: 'analyzing', - progress: 10, - message: 'Refreshing roadmap...' - } as RoadmapGenerationStatus - ); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.ROADMAP_PROGRESS, projectId, { + phase: "analyzing", + progress: 10, + message: "Refreshing roadmap...", + } as RoadmapGenerationStatus); } ); - ipcMain.handle( - IPC_CHANNELS.ROADMAP_STOP, - async (_, projectId: string): Promise => { - debugLog('[Roadmap Handler] Stop generation request:', { projectId }); - - const mainWindow = getMainWindow(); - - // Stop roadmap generation for this project - const wasStopped = agentManager.stopRoadmap(projectId); + ipcMain.handle(IPC_CHANNELS.ROADMAP_STOP, async (_, projectId: string): Promise => { + debugLog("[Roadmap Handler] Stop generation request:", { projectId }); - debugLog('[Roadmap Handler] Stop result:', { projectId, wasStopped }); + // Stop roadmap generation for this project + const wasStopped = agentManager.stopRoadmap(projectId); - if (wasStopped && mainWindow) { - debugLog('[Roadmap Handler] Sending stopped event to renderer'); - mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_STOPPED, projectId); - } + debugLog("[Roadmap Handler] Stop result:", { projectId, wasStopped }); - return { success: wasStopped }; + if (wasStopped) { + debugLog("[Roadmap Handler] Sending stopped event to renderer"); + safeSendToRenderer(getMainWindow, IPC_CHANNELS.ROADMAP_STOPPED, projectId); } - ); + + return { success: wasStopped }; + }); // ============================================ // Roadmap Save (full state persistence for drag-and-drop) @@ -342,14 +365,10 @@ export function registerRoadmapHandlers( ipcMain.handle( IPC_CHANNELS.ROADMAP_SAVE, - async ( - _, - projectId: string, - roadmapData: Roadmap - ): Promise => { + async (_, projectId: string, roadmapData: Roadmap): Promise => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } const roadmapPath = path.join( @@ -359,11 +378,11 @@ export function registerRoadmapHandlers( ); if (!existsSync(roadmapPath)) { - return { success: false, error: 'Roadmap not found' }; + return { success: false, error: "Roadmap not found" }; } try { - const content = readFileSync(roadmapPath, 'utf-8'); + const content = readFileSync(roadmapPath, "utf-8"); const existingRoadmap = JSON.parse(content); // Transform camelCase features back to snake_case for JSON file @@ -371,7 +390,7 @@ export function registerRoadmapHandlers( id: feature.id, title: feature.title, description: feature.description, - rationale: feature.rationale || '', + rationale: feature.rationale || "", priority: feature.priority, complexity: feature.complexity, impact: feature.impact, @@ -381,7 +400,7 @@ export function registerRoadmapHandlers( acceptance_criteria: feature.acceptanceCriteria || [], user_stories: feature.userStories || [], linked_spec_id: feature.linkedSpecId, - competitor_insight_ids: feature.competitorInsightIds + competitor_insight_ids: feature.competitorInsightIds, })); // Update metadata timestamp @@ -394,7 +413,7 @@ export function registerRoadmapHandlers( } catch (error) { return { success: false, - error: error instanceof Error ? error.message : 'Failed to save roadmap' + error: error instanceof Error ? error.message : "Failed to save roadmap", }; } } @@ -410,7 +429,7 @@ export function registerRoadmapHandlers( ): Promise => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } const roadmapPath = path.join( @@ -420,17 +439,17 @@ export function registerRoadmapHandlers( ); if (!existsSync(roadmapPath)) { - return { success: false, error: 'Roadmap not found' }; + return { success: false, error: "Roadmap not found" }; } try { - const content = readFileSync(roadmapPath, 'utf-8'); + const content = readFileSync(roadmapPath, "utf-8"); const roadmap = JSON.parse(content); // Find and update the feature const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); if (!feature) { - return { success: false, error: 'Feature not found' }; + return { success: false, error: "Feature not found" }; } feature.status = status; @@ -443,7 +462,7 @@ export function registerRoadmapHandlers( } catch (error) { return { success: false, - error: error instanceof Error ? error.message : 'Failed to update feature' + error: error instanceof Error ? error.message : "Failed to update feature", }; } } @@ -451,14 +470,10 @@ export function registerRoadmapHandlers( ipcMain.handle( IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, - async ( - _, - projectId: string, - featureId: string - ): Promise> => { + async (_, projectId: string, featureId: string): Promise> => { const project = projectStore.getProject(projectId); if (!project) { - return { success: false, error: 'Project not found' }; + return { success: false, error: "Project not found" }; } const roadmapPath = path.join( @@ -468,17 +483,17 @@ export function registerRoadmapHandlers( ); if (!existsSync(roadmapPath)) { - return { success: false, error: 'Roadmap not found' }; + return { success: false, error: "Roadmap not found" }; } try { - const content = readFileSync(roadmapPath, 'utf-8'); + const content = readFileSync(roadmapPath, "utf-8"); const roadmap = JSON.parse(content); // Find the feature const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); if (!feature) { - return { success: false, error: 'Feature not found' }; + return { success: false, error: "Feature not found" }; } // Build task description from feature @@ -487,17 +502,17 @@ export function registerRoadmapHandlers( ${feature.description} ## Rationale -${feature.rationale || 'N/A'} +${feature.rationale || "N/A"} ## User Stories -${(feature.user_stories || []).map((s: string) => `- ${s}`).join('\n') || 'N/A'} +${(feature.user_stories || []).map((s: string) => `- ${s}`).join("\n") || "N/A"} ## Acceptance Criteria -${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n') || 'N/A'} +${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n") || "N/A"} `; // Generate proper spec directory (like task creation) - const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsBaseDir = getSpecsDir(project.autoBuildPath); const specsDir = path.join(project.path, specsBaseDir); // Ensure specs directory exists @@ -509,15 +524,15 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n' let specNumber = 1; const existingDirs = existsSync(specsDir) ? readdirSync(specsDir, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name) + .filter((d) => d.isDirectory()) + .map((d) => d.name) : []; const existingNumbers = existingDirs - .map(name => { + .map((name) => { const match = name.match(/^(\d+)/); return match ? parseInt(match[1], 10) : 0; }) - .filter(n => n > 0); + .filter((n) => n > 0); if (existingNumbers.length > 0) { specNumber = Math.max(...existingNumbers) + 1; } @@ -525,10 +540,10 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n' // Create spec ID with zero-padded number and slugified title const slugifiedTitle = feature.title .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") .substring(0, 50); - const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + const specId = `${String(specNumber).padStart(3, "0")}-${slugifiedTitle}`; // Create spec directory const specDir = path.join(specsDir, specId); @@ -541,34 +556,40 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n' description: taskDescription, created_at: now, updated_at: now, - status: 'pending', - phases: [] + status: "pending", + phases: [], }; - writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + writeFileSync( + path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), + JSON.stringify(implementationPlan, null, 2) + ); // Create requirements.json const requirements = { task_description: taskDescription, - workflow_type: 'feature' + workflow_type: "feature", }; - writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + writeFileSync( + path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), + JSON.stringify(requirements, null, 2) + ); // Create spec.md (required by backend spec creation process) writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), taskDescription); // Build metadata const metadata: TaskMetadata = { - sourceType: 'roadmap', + sourceType: "roadmap", featureId: feature.id, - category: 'feature' + category: "feature", }; - writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + writeFileSync(path.join(specDir, "task_metadata.json"), JSON.stringify(metadata, null, 2)); // NOTE: We do NOT auto-start spec creation here - user should explicitly start the task // from the kanban board when they're ready // Update feature with linked spec - feature.status = 'planned'; + feature.status = "planned"; feature.linked_spec_id = specId; roadmap.metadata = roadmap.metadata || {}; roadmap.metadata.updated_at = new Date().toISOString(); @@ -581,19 +602,19 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n' projectId, title: feature.title, description: taskDescription, - status: 'backlog', + status: "backlog", subtasks: [], logs: [], metadata, createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), }; return { success: true, data: task }; } catch (error) { return { success: false, - error: error instanceof Error ? error.message : 'Failed to convert feature to spec' + error: error instanceof Error ? error.message : "Failed to convert feature to spec", }; } } @@ -603,25 +624,15 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n' // Roadmap Agent Events → Renderer // ============================================ - agentManager.on('roadmap-progress', (projectId: string, status: RoadmapGenerationStatus) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_PROGRESS, projectId, status); - } + agentManager.on("roadmap-progress", (projectId: string, status: RoadmapGenerationStatus) => { + safeSendToRenderer(getMainWindow, IPC_CHANNELS.ROADMAP_PROGRESS, projectId, status); }); - agentManager.on('roadmap-complete', (projectId: string, roadmap: Roadmap) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_COMPLETE, projectId, roadmap); - } + agentManager.on("roadmap-complete", (projectId: string, roadmap: Roadmap) => { + safeSendToRenderer(getMainWindow, IPC_CHANNELS.ROADMAP_COMPLETE, projectId, roadmap); }); - agentManager.on('roadmap-error', (projectId: string, error: string) => { - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_ERROR, projectId, error); - } + agentManager.on("roadmap-error", (projectId: string, error: string) => { + safeSendToRenderer(getMainWindow, IPC_CHANNELS.ROADMAP_ERROR, projectId, error); }); - } diff --git a/apps/frontend/src/main/ipc-handlers/utils.ts b/apps/frontend/src/main/ipc-handlers/utils.ts index 4bb738e050..cb113da1b9 100644 --- a/apps/frontend/src/main/ipc-handlers/utils.ts +++ b/apps/frontend/src/main/ipc-handlers/utils.ts @@ -2,19 +2,151 @@ * Shared utilities for IPC handlers */ +import type { BrowserWindow } from "electron"; + +/** + * Track last-warn timestamps per channel to prevent log spam. + * When a renderer frame is disposed, we log once per channel within cooldown period. + * Uses timestamp-based approach instead of setInterval to avoid timer leaks. + */ +const warnTimestamps = new Map(); +const WARN_COOLDOWN_MS = 5000; // 5 seconds between warnings per channel + +/** + * Check if a channel is within the warning cooldown period. + * @returns true if within cooldown (should skip warning), false if cooldown expired + */ +function isWithinCooldown(channel: string): boolean { + const lastWarn = warnTimestamps.get(channel) ?? 0; + return Date.now() - lastWarn < WARN_COOLDOWN_MS; +} + +/** + * Record a warning timestamp for a channel. + * Enforces a hard cap of 100 entries to prevent unbounded memory growth. + */ +function recordWarning(channel: string): void { + warnTimestamps.set(channel, Date.now()); + + // Prune if more than 100 entries to free memory + if (warnTimestamps.size > 100) { + const now = Date.now(); + + // First, remove expired entries + for (const [ch, ts] of warnTimestamps.entries()) { + if (now - ts >= WARN_COOLDOWN_MS) { + warnTimestamps.delete(ch); + } + } + + // If still over 100 entries, remove oldest (Map preserves insertion order) + if (warnTimestamps.size > 100) { + const entriesToRemove = warnTimestamps.size - 100; + let removed = 0; + for (const ch of warnTimestamps.keys()) { + warnTimestamps.delete(ch); + if (++removed >= entriesToRemove) { + break; + } + } + } + } +} + +/** + * Safely send IPC message to renderer with frame disposal checks + * + * This prevents "Render frame was disposed" errors that occur when: + * 1. Multiple agents are running and producing output + * 2. The main process tries to send data to renderer windows via webContents.send() + * 3. The renderer frame has been disposed/gone, but the main process hasn't detected this + * + * @param getMainWindow - Function to get the main window reference + * @param channel - IPC channel to send on + * @param args - Arguments to send to the renderer + * @returns true if message was sent, false if window was destroyed or not available + * + * @example + * ```ts + * // Instead of: + * mainWindow.webContents.send(IPC_CHANNELS.TASK_LOG, taskId, log); + * + * // Use: + * safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log); + * ``` + */ +export function safeSendToRenderer( + getMainWindow: () => BrowserWindow | null, + channel: string, + ...args: unknown[] +): boolean { + try { + const mainWindow = getMainWindow(); + + if (!mainWindow) { + return false; + } + + // Check if window or webContents is destroyed + // isDestroyed() returns true if the window has been closed and destroyed + if (mainWindow.isDestroyed()) { + if (!isWithinCooldown(channel)) { + console.warn(`[safeSendToRenderer] Skipping send to destroyed window: ${channel}`); + recordWarning(channel); + } + return false; + } + + // Check if webContents is destroyed (can happen independently of window) + if (!mainWindow.webContents || mainWindow.webContents.isDestroyed()) { + if (!isWithinCooldown(channel)) { + console.warn(`[safeSendToRenderer] Skipping send to destroyed webContents: ${channel}`); + recordWarning(channel); + } + return false; + } + + // All checks passed - safe to send + mainWindow.webContents.send(channel, ...args); + return true; + } catch (error) { + // Catch any disposal errors that might occur between our checks and the actual send + const errorMessage = error instanceof Error ? error.message : String(error); + + // Only log disposal errors once per channel to avoid log spam + if (errorMessage.includes("disposed") || errorMessage.includes("destroyed")) { + if (!isWithinCooldown(channel)) { + console.warn(`[safeSendToRenderer] Frame disposed, skipping send: ${channel}`); + recordWarning(channel); + } + } else { + console.error(`[safeSendToRenderer] Error sending to renderer:`, error); + } + return false; + } +} + +/** + * Clear the warning timestamps Map (for testing only) + */ +export function _clearWarnTimestampsForTest(): void { + warnTimestamps.clear(); +} + /** * Parse .env file into key-value object */ export function parseEnvFile(content: string): Record { const result: Record = {}; - const lines = content.split('\n'); + // Use /\r?\n/ to handle both \n (Unix) and \r\n (Windows) line endings + const lines = content.split(/\r?\n/); for (const line of lines) { const trimmed = line.trim(); // Skip empty lines and comments - if (!trimmed || trimmed.startsWith('#')) continue; + if (!trimmed || trimmed.startsWith("#")) continue; - const equalsIndex = trimmed.indexOf('='); + const equalsIndex = trimmed.indexOf("="); if (equalsIndex > 0) { const key = trimmed.substring(0, equalsIndex).trim(); let value = trimmed.substring(equalsIndex + 1).trim();