fix(frontend): prevent "Render frame was disposed" crash (ACS-211) (#918)
* fix(frontend): prevent "Render frame was disposed" crash (ACS-211) Add safeSendToRenderer helper that validates frame state before IPC sends. Prevents app crash when renderer frames are disposed during heavy agent output. Fixes #211 Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com> * refactor(utils): replace setInterval with timestamp-based cooldown, add \r\n support - Replace setInterval-based cooldown with timestamp Map approach to avoid timer leaks - Add isWithinCooldown() and recordWarning() helper functions - Optionally prune old entries when Map exceeds 100 entries - Update parseEnvFile to handle Windows \r\n line endings with /\r?\n/ regex Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com> * refactor(utils): enforce hard cap of 100 entries in pruning logic - First remove expired entries (outside cooldown period) - If still over 100 entries, remove oldest by insertion order - Ensures Map never exceeds 100 entries even during heavy load Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com> * test: fix module-level state issue in pruning tests - Add _clearWarnTimestampsForTest() helper to clear module-level Map - Call clear in parent beforeEach to ensure clean state for each test - Simplify pruning tests to avoid complex cooldown timing issues - All 28 tests now pass * fix: update generation-handlers.ts to use safeSendToRenderer Addresses finding NEW-003 from follow-up review: - Import safeSendToRenderer from '../utils' - Replace all 5 direct webContents.send() calls with safeSendToRenderer - Add getMainWindow wrapper for each function This ensures ideation generation IPC messages are protected from "Render frame was disposed" crashes. Related: ACS-211 --------- Signed-off-by: StillKnotKnown <stillknotknown@users.noreply.github.com> Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
This commit is contained in:
@@ -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<string, Function> = 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<string, Function>;
|
||||
invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>;
|
||||
@@ -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<typeof vi.fn> }; 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: ''
|
||||
}));
|
||||
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);
|
||||
const result = await ipcMain.invokeHandler("task:list", {}, projectId);
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
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) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, finalPlan, exitProjectId);
|
||||
// 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);
|
||||
|
||||
if (processType === "spec-creation") {
|
||||
console.warn(`[Task ${taskId}] Spec creation completed with code ${code}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let task: Task | undefined;
|
||||
let project: Project | undefined;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
fileWatcher.unwatch(taskId);
|
||||
|
||||
if (processType === 'spec-creation') {
|
||||
console.warn(`[Task ${taskId}] Spec creation completed with code ${code}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let task: Task | undefined;
|
||||
let project: Project | undefined;
|
||||
|
||||
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);
|
||||
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);
|
||||
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');
|
||||
// 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 (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;
|
||||
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
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, taskId, progress, taskProjectId);
|
||||
// Include projectId in execution progress event for multi-project filtering
|
||||
safeSendToRenderer(
|
||||
getMainWindow,
|
||||
IPC_CHANNELS.TASK_EXECUTION_PROGRESS,
|
||||
taskId,
|
||||
progress,
|
||||
taskProjectId
|
||||
);
|
||||
|
||||
const phaseToStatus: Record<string, TaskStatus | null> = {
|
||||
'idle': null,
|
||||
'planning': 'in_progress',
|
||||
'coding': 'in_progress',
|
||||
'qa_review': 'ai_review',
|
||||
'qa_fixing': 'ai_review',
|
||||
'complete': 'human_review',
|
||||
'failed': 'human_review'
|
||||
};
|
||||
const phaseToStatus: Record<string, TaskStatus | null> = {
|
||||
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
|
||||
);
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<IPCResult> {
|
||||
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 };
|
||||
|
||||
@@ -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<IPCResult<InsightsSession | null>> => {
|
||||
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<IPCResult> => {
|
||||
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<IPCResult<Task>> => {
|
||||
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<IPCResult<InsightsSessionSummary[]>> => {
|
||||
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<IPCResult<InsightsSession>> => {
|
||||
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<IPCResult<InsightsSession | null>> => {
|
||||
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<IPCResult> => {
|
||||
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<IPCResult> => {
|
||||
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<IPCResult> => {
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
modelConfig: InsightsModelConfig
|
||||
): Promise<IPCResult> => {
|
||||
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);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -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<IPCResult<Roadmap | null>> => {
|
||||
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<string, unknown>) => ({
|
||||
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<Record<string, unknown>>) || []).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<string, unknown>) => ({
|
||||
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<string, unknown>) => ({
|
||||
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<Record<string, unknown>> || []).map((m) => ({
|
||||
milestones: ((phase.milestones as Array<Record<string, unknown>>) || []).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<string, unknown>) => ({
|
||||
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<IPCResult<{ isRunning: boolean }>> => {
|
||||
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<IPCResult> => {
|
||||
debugLog('[Roadmap Handler] Stop generation request:', { projectId });
|
||||
ipcMain.handle(IPC_CHANNELS.ROADMAP_STOP, async (_, projectId: string): Promise<IPCResult> => {
|
||||
debugLog("[Roadmap Handler] Stop generation request:", { projectId });
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
// Stop roadmap generation for this project
|
||||
const wasStopped = agentManager.stopRoadmap(projectId);
|
||||
|
||||
// Stop roadmap generation for this project
|
||||
const wasStopped = agentManager.stopRoadmap(projectId);
|
||||
debugLog("[Roadmap Handler] Stop result:", { projectId, wasStopped });
|
||||
|
||||
debugLog('[Roadmap Handler] Stop result:', { projectId, wasStopped });
|
||||
|
||||
if (wasStopped && mainWindow) {
|
||||
debugLog('[Roadmap Handler] Sending stopped event to renderer');
|
||||
mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_STOPPED, projectId);
|
||||
}
|
||||
|
||||
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<IPCResult> => {
|
||||
async (_, projectId: string, roadmapData: Roadmap): Promise<IPCResult> => {
|
||||
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<IPCResult> => {
|
||||
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<IPCResult<Task>> => {
|
||||
async (_, projectId: string, featureId: string): Promise<IPCResult<Task>> => {
|
||||
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);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user