Refcator for better code quality
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,268 +0,0 @@
|
||||
# Auto-Claude Recovery and Resumption System
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains how Auto-Claude detects completed planning phases, identifies existing worktrees, and resumes execution after crashes or user interruptions.
|
||||
|
||||
## Key State Detection Mechanisms
|
||||
|
||||
### 1. Planning Phase Completion Detection
|
||||
|
||||
**Primary Indicator: `implementation_plan.json`**
|
||||
|
||||
The system determines if planning is complete by checking for the existence and validity of `implementation_plan.json`:
|
||||
|
||||
- **Location**: `.auto-claude/specs/{spec-name}/implementation_plan.json`
|
||||
- **Detection Logic**:
|
||||
- `prompts.py::is_first_run()` checks if `implementation_plan.json` exists
|
||||
- `coordinator.py::load_implementation_plan()` raises `FileNotFoundError` if missing
|
||||
- `agent.py::load_implementation_plan()` returns `None` if file doesn't exist
|
||||
|
||||
**Code References:**
|
||||
```python
|
||||
# auto-claude/prompts.py:181-192
|
||||
def is_first_run(spec_dir: Path) -> bool:
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
return not plan_file.exists()
|
||||
|
||||
# auto-claude/coordinator.py:175-186
|
||||
def load_implementation_plan(self) -> ImplementationPlan:
|
||||
plan_file = self.spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
raise FileNotFoundError("Implementation plan not found")
|
||||
# ...
|
||||
```
|
||||
|
||||
**Planning Phase Flow:**
|
||||
1. `spec_runner.py` runs phases: discovery → requirements → complexity → context → spec → planning
|
||||
2. `phase_planning()` creates `implementation_plan.json` via `planner.py` or planner agent
|
||||
3. Once `implementation_plan.json` exists and is valid, planning is considered complete
|
||||
|
||||
### 2. Worktree Detection
|
||||
|
||||
**Staging Worktree Detection**
|
||||
|
||||
The system checks for existing worktrees using:
|
||||
|
||||
- **Location**: `.worktrees/auto-claude/` (staging worktree)
|
||||
- **Detection Functions**:
|
||||
- `workspace.py::get_existing_build_worktree()` - checks if staging worktree exists
|
||||
- `worktree.py::get_or_create_staging()` - gets existing or creates new staging worktree
|
||||
- `worktree.py::staging_exists()` - boolean check for staging worktree
|
||||
|
||||
**Code References:**
|
||||
```python
|
||||
# auto-claude/workspace.py:88-93
|
||||
def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Optional[Path]:
|
||||
worktree_path = project_dir / ".worktrees" / STAGING_WORKTREE_NAME
|
||||
if worktree_path.exists():
|
||||
return worktree_path
|
||||
return None
|
||||
|
||||
# auto-claude/worktree.py:173-206
|
||||
def get_or_create_staging(self, spec_name: str) -> WorktreeInfo:
|
||||
staging_path = self.worktrees_dir / STAGING_WORKTREE_NAME
|
||||
branch_name = f"auto-claude/{spec_name}"
|
||||
|
||||
if staging_path.exists():
|
||||
# Load existing worktree info
|
||||
# ...
|
||||
return info
|
||||
# Create new staging worktree
|
||||
return self.create(STAGING_WORKTREE_NAME, branch_name)
|
||||
```
|
||||
|
||||
**Worktree Persistence:**
|
||||
- Worktrees persist across crashes and app restarts
|
||||
- Git worktrees are stored in `.worktrees/` directory
|
||||
- Each worktree has its own branch: `auto-claude/{spec-name}`
|
||||
- Staging worktree is preserved until explicitly merged or discarded
|
||||
|
||||
### 3. Resumption Logic
|
||||
|
||||
**Entry Point: `run.py::main()`**
|
||||
|
||||
When `run.py` starts, it follows this resumption flow:
|
||||
|
||||
1. **Find Spec**: Locates spec directory via `find_spec()`
|
||||
2. **Check Existing Build**: Calls `get_existing_build_worktree()` to detect staging worktree
|
||||
3. **User Choice** (if not `--auto-continue`):
|
||||
- Continue where it left off
|
||||
- Review what was built
|
||||
- Merge existing build
|
||||
- Start fresh (discard)
|
||||
4. **Workspace Setup**:
|
||||
- If existing worktree found → uses it
|
||||
- If not → creates new staging worktree
|
||||
5. **Execution**:
|
||||
- **Sequential Mode**: `run_autonomous_agent()` checks for `implementation_plan.json`
|
||||
- **Parallel Mode**: `SwarmCoordinator` checks for plan, runs planner if missing
|
||||
|
||||
**Code Flow:**
|
||||
```python
|
||||
# auto-claude/run.py:618-631
|
||||
if get_existing_build_worktree(project_dir, spec_dir.name):
|
||||
if args.auto_continue:
|
||||
print("Auto-continue: Resuming existing build...")
|
||||
else:
|
||||
continue_existing = check_existing_build(project_dir, spec_dir.name)
|
||||
if continue_existing:
|
||||
# Continue with existing worktree
|
||||
pass
|
||||
else:
|
||||
# User chose to start fresh or merged existing
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. Planner vs Coder Detection
|
||||
|
||||
**Sequential Mode (`agent.py`):**
|
||||
|
||||
```python
|
||||
# auto-claude/agent.py:547-691
|
||||
async def run_autonomous_agent(...):
|
||||
# ...
|
||||
first_run = is_first_run(spec_dir) # Checks for implementation_plan.json
|
||||
|
||||
if first_run:
|
||||
# Run planner session
|
||||
prompt = generate_planner_prompt(spec_dir)
|
||||
else:
|
||||
# Run coder session
|
||||
next_chunk = get_next_chunk(spec_dir)
|
||||
prompt = generate_coder_prompt(spec_dir, next_chunk)
|
||||
```
|
||||
|
||||
**Parallel Mode (`coordinator.py`):**
|
||||
|
||||
```python
|
||||
# auto-claude/coordinator.py:304-350
|
||||
async def run_planner_session(self) -> bool:
|
||||
"""Run planner if implementation_plan.json doesn't exist"""
|
||||
plan_file = self.spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
# Run planner agent session
|
||||
# ...
|
||||
```
|
||||
|
||||
### 5. Chunk Progress Tracking
|
||||
|
||||
**State Storage: `implementation_plan.json`**
|
||||
|
||||
Chunk statuses are stored in the plan file:
|
||||
- `pending` - Not started
|
||||
- `in_progress` - Currently being worked on
|
||||
- `completed` - Finished successfully
|
||||
- `blocked` - Stuck, needs attention
|
||||
- `failed` - Failed after attempts
|
||||
|
||||
**Resumption Logic:**
|
||||
```python
|
||||
# auto-claude/progress.py:399-452
|
||||
def get_next_chunk(spec_dir: Path) -> dict | None:
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
# ...
|
||||
# Find first pending chunk in available phase
|
||||
for chunk in phase.get("chunks", []):
|
||||
if chunk.get("status") == "pending":
|
||||
return chunk
|
||||
```
|
||||
|
||||
**Recovery Handling:**
|
||||
- `in_progress` chunks are reset to `pending` on recovery (see `ipc-handlers.ts:926`)
|
||||
- Completed chunks remain `completed` to preserve progress
|
||||
- Failed chunks can be reset to `pending` for retry
|
||||
|
||||
## Recovery Scenarios
|
||||
|
||||
### Scenario 1: Planning Complete, No Worktree
|
||||
|
||||
**State:**
|
||||
- ✅ `implementation_plan.json` exists
|
||||
- ❌ No staging worktree exists
|
||||
|
||||
**Behavior:**
|
||||
- System detects plan exists → skips planner
|
||||
- Creates new staging worktree
|
||||
- Starts coding phase with first pending chunk
|
||||
|
||||
### Scenario 2: Planning Complete, Worktree Exists
|
||||
|
||||
**State:**
|
||||
- ✅ `implementation_plan.json` exists
|
||||
- ✅ Staging worktree exists at `.worktrees/auto-claude/`
|
||||
|
||||
**Behavior:**
|
||||
- System detects both exist
|
||||
- Prompts user (or auto-continues) to resume
|
||||
- Uses existing worktree
|
||||
- Resumes from last incomplete chunk
|
||||
|
||||
### Scenario 3: Planning Incomplete
|
||||
|
||||
**State:**
|
||||
- ❌ `implementation_plan.json` missing or invalid
|
||||
- ❌ No staging worktree (or exists but no plan)
|
||||
|
||||
**Behavior:**
|
||||
- System detects missing plan
|
||||
- Runs planner session first
|
||||
- Creates `implementation_plan.json`
|
||||
- Then proceeds to coding phase
|
||||
|
||||
### Scenario 4: Crash During Coding
|
||||
|
||||
**State:**
|
||||
- ✅ `implementation_plan.json` exists
|
||||
- ✅ Staging worktree exists
|
||||
- ⚠️ Some chunks `in_progress` or `failed`
|
||||
|
||||
**Behavior:**
|
||||
- On restart, `in_progress` chunks are reset to `pending`
|
||||
- System resumes from first `pending` chunk
|
||||
- Worktree preserves all committed work
|
||||
- Uncommitted changes may be lost (depends on git state)
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
**UI State Detection (`auto-claude-ui`):**
|
||||
|
||||
The frontend tracks state via:
|
||||
1. **File Watcher**: Monitors `implementation_plan.json` for changes
|
||||
2. **Task Store**: Reads plan file to determine task status
|
||||
3. **Agent Manager**: Parses logs to detect phase transitions
|
||||
|
||||
**Recovery Actions:**
|
||||
- `ipc-handlers.ts::recoverTask()` - Resets stuck tasks
|
||||
- Resets `in_progress` → `pending` for interrupted chunks
|
||||
- Preserves `completed` chunks
|
||||
|
||||
## Key Files for State
|
||||
|
||||
**State Files:**
|
||||
- `.auto-claude/specs/{spec}/implementation_plan.json` - Main state file
|
||||
- `.auto-claude/specs/{spec}/memory/attempt_history.json` - Recovery history
|
||||
- `.auto-claude/specs/{spec}/memory/build_commits.json` - Git commit tracking
|
||||
- `.worktrees/auto-claude/` - Staging worktree (git-managed)
|
||||
|
||||
**Detection Functions:**
|
||||
- `is_first_run()` - Checks if planning needed
|
||||
- `get_existing_build_worktree()` - Checks for worktree
|
||||
- `get_next_chunk()` - Finds next work item
|
||||
- `load_implementation_plan()` - Loads plan state
|
||||
|
||||
## Summary
|
||||
|
||||
The system uses a **file-based state detection** approach:
|
||||
|
||||
1. **Planning Complete?** → Check for `implementation_plan.json`
|
||||
2. **Worktree Exists?** → Check for `.worktrees/auto-claude/`
|
||||
3. **What's Next?** → Read chunk statuses from `implementation_plan.json`
|
||||
4. **Resume Point** → First `pending` chunk in available phase
|
||||
|
||||
This design ensures:
|
||||
- ✅ No duplicate planning if plan already exists
|
||||
- ✅ Work preservation via git worktrees
|
||||
- ✅ Progress tracking via chunk statuses
|
||||
- ✅ Safe resumption after crashes
|
||||
- ✅ User control over continuation vs fresh start
|
||||
@@ -6,6 +6,10 @@ out/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Compiled TypeScript (source files are .ts)
|
||||
src/**/*.js
|
||||
src/**/*.js.map
|
||||
|
||||
# electron-vite
|
||||
.vite/
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
describe('AgentManager', () => {
|
||||
it('should spawn Python process for spec creation', async () => {
|
||||
const { spawn } = await import('child_process');
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test task description');
|
||||
@@ -95,7 +95,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
|
||||
it('should spawn Python process for task execution', async () => {
|
||||
const { spawn } = await import('child_process');
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001');
|
||||
@@ -111,7 +111,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
|
||||
it('should spawn Python process for QA process', async () => {
|
||||
const { spawn } = await import('child_process');
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
manager.startQAProcess('task-1', TEST_PROJECT_PATH, 'spec-001');
|
||||
@@ -132,7 +132,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
|
||||
it('should include parallel options when specified', async () => {
|
||||
const { spawn } = await import('child_process');
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001', {
|
||||
@@ -148,7 +148,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
});
|
||||
|
||||
it('should emit log events from stdout', async () => {
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
const logHandler = vi.fn();
|
||||
@@ -163,7 +163,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
});
|
||||
|
||||
it('should emit log events from stderr', async () => {
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
const logHandler = vi.fn();
|
||||
@@ -178,7 +178,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
});
|
||||
|
||||
it('should emit exit event when process exits', async () => {
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
const exitHandler = vi.fn();
|
||||
@@ -193,7 +193,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
});
|
||||
|
||||
it('should emit error event when process errors', async () => {
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
const errorHandler = vi.fn();
|
||||
@@ -208,7 +208,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
});
|
||||
|
||||
it('should kill task and remove from tracking', async () => {
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
|
||||
@@ -223,7 +223,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
});
|
||||
|
||||
it('should return false when killing non-existent task', async () => {
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
const result = manager.killTask('nonexistent');
|
||||
@@ -232,7 +232,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
});
|
||||
|
||||
it('should track running tasks', async () => {
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
expect(manager.getRunningTasks()).toHaveLength(0);
|
||||
@@ -246,7 +246,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
|
||||
it('should use configured Python path', async () => {
|
||||
const { spawn } = await import('child_process');
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
manager.configure('/custom/python3');
|
||||
@@ -261,7 +261,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
});
|
||||
|
||||
it('should kill all running tasks', async () => {
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
|
||||
@@ -273,7 +273,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
});
|
||||
|
||||
it('should kill existing process when starting new one for same task', async () => {
|
||||
const { AgentManager } = await import('../../main/agent-manager');
|
||||
const { AgentManager } = await import('../../main/agent');
|
||||
|
||||
const manager = new AgentManager();
|
||||
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
|
||||
|
||||
@@ -91,6 +91,11 @@ describe('IPC Handlers', () => {
|
||||
invokeClaude: ReturnType<typeof vi.fn>;
|
||||
killAll: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockPythonEnvManager: {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
getStatus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
cleanupTestDirs();
|
||||
@@ -125,6 +130,12 @@ describe('IPC Handlers', () => {
|
||||
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 }))
|
||||
};
|
||||
|
||||
// Need to reset modules to re-register handlers
|
||||
vi.resetModules();
|
||||
});
|
||||
@@ -137,7 +148,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
const result = await ipcMain.invokeHandler('project:add', {}, '/nonexistent/path');
|
||||
|
||||
@@ -149,7 +160,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
it('should successfully add an existing project', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
const result = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH);
|
||||
|
||||
@@ -162,7 +173,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
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);
|
||||
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);
|
||||
@@ -177,7 +188,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
const result = await ipcMain.invokeHandler('project:list', {});
|
||||
|
||||
@@ -189,7 +200,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
it('should return all added projects', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
// Add a project
|
||||
await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH);
|
||||
@@ -205,7 +216,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
const result = await ipcMain.invokeHandler('project:remove', {}, 'nonexistent-id');
|
||||
|
||||
@@ -214,7 +225,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
it('should successfully remove an existing project', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
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);
|
||||
@@ -235,7 +246,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'project:updateSettings',
|
||||
@@ -252,7 +263,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
it('should successfully update project settings', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
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);
|
||||
@@ -273,7 +284,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
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);
|
||||
@@ -289,7 +300,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
it('should return tasks when specs exist', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
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);
|
||||
@@ -325,7 +336,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'task:create',
|
||||
@@ -343,7 +354,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
it('should create task and start spec creation', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
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);
|
||||
@@ -365,7 +376,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
const result = await ipcMain.invokeHandler('settings:get', {});
|
||||
|
||||
@@ -378,7 +389,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'settings:save',
|
||||
@@ -397,7 +408,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
await ipcMain.invokeHandler(
|
||||
'settings:save',
|
||||
@@ -412,7 +423,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
const result = await ipcMain.invokeHandler('app:version', {});
|
||||
|
||||
@@ -423,7 +434,7 @@ describe('IPC Handlers', () => {
|
||||
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);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
mockAgentManager.emit('log', 'task-1', 'Test log message');
|
||||
|
||||
@@ -436,7 +447,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
it('should forward error events to renderer', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
mockAgentManager.emit('error', 'task-1', 'Test error message');
|
||||
|
||||
@@ -449,7 +460,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
it('should forward exit events with status change', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never);
|
||||
|
||||
mockAgentManager.emit('exit', 'task-1', 0);
|
||||
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 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, writeFileSync, rmSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Test data directory
|
||||
const TEST_DIR = '/tmp/ipc-handlers-test';
|
||||
const TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
|
||||
|
||||
// Mock modules before importing
|
||||
vi.mock('electron', () => {
|
||||
const mockIpcMain = new (class extends EventEmitter {
|
||||
private handlers: Map<string, Function> = new Map();
|
||||
|
||||
handle(channel: string, handler: Function): void {
|
||||
this.handlers.set(channel, handler);
|
||||
}
|
||||
|
||||
removeHandler(channel: string): void {
|
||||
this.handlers.delete(channel);
|
||||
}
|
||||
|
||||
async invokeHandler(channel: string, event: unknown, ...args: unknown[]): Promise<unknown> {
|
||||
const handler = this.handlers.get(channel);
|
||||
if (handler) {
|
||||
return handler(event, ...args);
|
||||
}
|
||||
throw new Error(`No handler for channel: ${channel}`);
|
||||
}
|
||||
|
||||
getHandler(channel: string): Function | undefined {
|
||||
return this.handlers.get(channel);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => {
|
||||
if (name === 'userData') return path.join(TEST_DIR, 'userData');
|
||||
return TEST_DIR;
|
||||
}),
|
||||
getVersion: vi.fn(() => '0.1.0'),
|
||||
isPackaged: false
|
||||
},
|
||||
ipcMain: mockIpcMain,
|
||||
dialog: {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Cleanup test directories
|
||||
function cleanupTestDirs(): void {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('IPC Handlers', () => {
|
||||
let ipcMain: EventEmitter & {
|
||||
handlers: Map<string, Function>;
|
||||
invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>;
|
||||
getHandler: (channel: string) => Function | undefined;
|
||||
};
|
||||
let mockMainWindow: { webContents: { send: ReturnType<typeof vi.fn> } };
|
||||
let mockAgentManager: EventEmitter & {
|
||||
startSpecCreation: ReturnType<typeof vi.fn>;
|
||||
startTaskExecution: ReturnType<typeof vi.fn>;
|
||||
startQAProcess: ReturnType<typeof vi.fn>;
|
||||
killTask: ReturnType<typeof vi.fn>;
|
||||
configure: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockTerminalManager: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
resize: ReturnType<typeof vi.fn>;
|
||||
invokeClaude: ReturnType<typeof vi.fn>;
|
||||
killAll: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
cleanupTestDirs();
|
||||
setupTestProject();
|
||||
mkdirSync(path.join(TEST_DIR, 'userData', 'store'), { recursive: true });
|
||||
|
||||
// Get mocked ipcMain
|
||||
const electron = await import('electron');
|
||||
ipcMain = electron.ipcMain as unknown as typeof ipcMain;
|
||||
|
||||
// Create mock window
|
||||
mockMainWindow = {
|
||||
webContents: { send: vi.fn() }
|
||||
};
|
||||
|
||||
// Create mock agent manager
|
||||
mockAgentManager = Object.assign(new EventEmitter(), {
|
||||
startSpecCreation: vi.fn(),
|
||||
startTaskExecution: vi.fn(),
|
||||
startQAProcess: vi.fn(),
|
||||
killTask: vi.fn(),
|
||||
configure: vi.fn()
|
||||
});
|
||||
|
||||
// Create mock terminal manager
|
||||
mockTerminalManager = {
|
||||
create: vi.fn(() => Promise.resolve({ success: true })),
|
||||
destroy: vi.fn(() => Promise.resolve({ success: true })),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
invokeClaude: vi.fn(),
|
||||
killAll: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
|
||||
// Need to reset modules to re-register handlers
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTestDirs();
|
||||
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);
|
||||
|
||||
const result = await ipcMain.invokeHandler('project:add', {}, '/nonexistent/path');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
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);
|
||||
|
||||
const result = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Add project twice
|
||||
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;
|
||||
expect(data1.id).toBe(data2.id);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const result = await ipcMain.invokeHandler('project:list', {});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: []
|
||||
});
|
||||
});
|
||||
|
||||
it('should return all added projects', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
|
||||
// Add a project
|
||||
await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH);
|
||||
|
||||
const result = await ipcMain.invokeHandler('project:list', {});
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
// Add a project first
|
||||
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);
|
||||
|
||||
expect(removeResult).toEqual({ success: true });
|
||||
|
||||
// Verify it's gone
|
||||
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);
|
||||
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'project:updateSettings',
|
||||
{},
|
||||
'nonexistent-id',
|
||||
{ parallelEnabled: true }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
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);
|
||||
|
||||
// Add a project first
|
||||
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,
|
||||
{ parallelEnabled: true, maxWorkers: 4 }
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// Add a project first
|
||||
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);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
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);
|
||||
|
||||
// Add a project first
|
||||
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
|
||||
const specDir = path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs', '001-test-feature');
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify({
|
||||
feature: 'Test Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
phases: [{
|
||||
phase: 1,
|
||||
name: 'Test Phase',
|
||||
type: 'implementation',
|
||||
subtasks: [{ id: 'subtask-1', description: 'Test subtask', status: 'pending' }]
|
||||
}],
|
||||
final_acceptance: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
spec_file: ''
|
||||
}));
|
||||
|
||||
const result = await ipcMain.invokeHandler('task:list', {}, projectId);
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
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);
|
||||
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'task:create',
|
||||
{},
|
||||
'nonexistent-id',
|
||||
'Test Task',
|
||||
'Test description'
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Project not found'
|
||||
});
|
||||
});
|
||||
|
||||
it('should create task and start spec creation', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
|
||||
// Add a project first
|
||||
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',
|
||||
{},
|
||||
projectId,
|
||||
'Test Task',
|
||||
'Test description'
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(mockAgentManager.startSpecCreation).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const result = await ipcMain.invokeHandler('settings:get', {});
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { theme: string } }).data;
|
||||
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);
|
||||
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'settings:save',
|
||||
{},
|
||||
{ theme: 'dark', defaultModel: 'opus' }
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
// Verify settings were saved
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
const result = await ipcMain.invokeHandler('app:version', {});
|
||||
|
||||
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);
|
||||
|
||||
mockAgentManager.emit('log', 'task-1', 'Test log message');
|
||||
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
|
||||
'task:log',
|
||||
'task-1',
|
||||
'Test log message'
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward error events to renderer', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
|
||||
mockAgentManager.emit('error', 'task-1', 'Test error message');
|
||||
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
|
||||
'task:error',
|
||||
'task-1',
|
||||
'Test error message'
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward exit events with status change', async () => {
|
||||
const { setupIpcHandlers } = await import('../ipc-handlers');
|
||||
setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never);
|
||||
|
||||
mockAgentManager.emit('exit', 'task-1', 0);
|
||||
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
|
||||
'task:statusChange',
|
||||
'task-1',
|
||||
'ai_review'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
import { ExecutionProgressData } from './types';
|
||||
|
||||
/**
|
||||
* Event handling and progress parsing logic
|
||||
*/
|
||||
export class AgentEvents {
|
||||
/**
|
||||
* Parse log output to detect execution phase transitions
|
||||
*/
|
||||
parseExecutionPhase(
|
||||
log: string,
|
||||
currentPhase: ExecutionProgressData['phase'],
|
||||
isSpecRunner: boolean
|
||||
): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null {
|
||||
const lowerLog = log.toLowerCase();
|
||||
|
||||
// Spec runner phase detection (all part of "planning")
|
||||
if (isSpecRunner) {
|
||||
if (lowerLog.includes('discovering') || lowerLog.includes('discovery')) {
|
||||
return { phase: 'planning', message: 'Discovering project context...' };
|
||||
}
|
||||
if (lowerLog.includes('requirements') || lowerLog.includes('gathering')) {
|
||||
return { phase: 'planning', message: 'Gathering requirements...' };
|
||||
}
|
||||
if (lowerLog.includes('writing spec') || lowerLog.includes('spec writer')) {
|
||||
return { phase: 'planning', message: 'Writing specification...' };
|
||||
}
|
||||
if (lowerLog.includes('validating') || lowerLog.includes('validation')) {
|
||||
return { phase: 'planning', message: 'Validating specification...' };
|
||||
}
|
||||
if (lowerLog.includes('spec complete') || lowerLog.includes('specification complete')) {
|
||||
return { phase: 'planning', message: 'Specification complete' };
|
||||
}
|
||||
}
|
||||
|
||||
// Run.py phase detection
|
||||
// Planner agent running
|
||||
if (lowerLog.includes('planner agent') || lowerLog.includes('creating implementation plan')) {
|
||||
return { phase: 'planning', message: 'Creating implementation plan...' };
|
||||
}
|
||||
|
||||
// Coder agent running
|
||||
if (lowerLog.includes('coder agent') || lowerLog.includes('starting coder')) {
|
||||
return { phase: 'coding', message: 'Implementing code changes...' };
|
||||
}
|
||||
|
||||
// Subtask progress detection
|
||||
const subtaskMatch = log.match(/subtask[:\s]+(\d+(?:\/\d+)?|\w+[-_]\w+)/i);
|
||||
if (subtaskMatch && currentPhase === 'coding') {
|
||||
return { phase: 'coding', currentSubtask: subtaskMatch[1], message: `Working on subtask ${subtaskMatch[1]}...` };
|
||||
}
|
||||
|
||||
// Subtask completion detection
|
||||
if (lowerLog.includes('subtask completed') || lowerLog.includes('subtask done')) {
|
||||
const completedSubtask = log.match(/subtask[:\s]+"?([^"]+)"?\s+completed/i);
|
||||
return {
|
||||
phase: 'coding',
|
||||
currentSubtask: completedSubtask?.[1],
|
||||
message: `Subtask ${completedSubtask?.[1] || ''} completed`
|
||||
};
|
||||
}
|
||||
|
||||
// QA Review phase
|
||||
if (lowerLog.includes('qa reviewer') || lowerLog.includes('qa_reviewer') || lowerLog.includes('starting qa')) {
|
||||
return { phase: 'qa_review', message: 'Running QA review...' };
|
||||
}
|
||||
|
||||
// QA Fixer phase
|
||||
if (lowerLog.includes('qa fixer') || lowerLog.includes('qa_fixer') || lowerLog.includes('fixing issues')) {
|
||||
return { phase: 'qa_fixing', message: 'Fixing QA issues...' };
|
||||
}
|
||||
|
||||
// Completion detection - be conservative, require explicit success markers
|
||||
// The AI agent prints "=== BUILD COMPLETE ===" when truly done (from coder.md)
|
||||
// Only trust this pattern, not generic "all subtasks completed" which could be false positive
|
||||
if (lowerLog.includes('=== build complete ===') || lowerLog.includes('qa passed')) {
|
||||
return { phase: 'complete', message: 'Build completed successfully' };
|
||||
}
|
||||
|
||||
// "All subtasks completed" is informational - don't change phase based on this alone
|
||||
// The coordinator may print this even when subtasks are blocked, so we stay in coding phase
|
||||
// and let the actual implementation_plan.json status drive the UI
|
||||
if (lowerLog.includes('all subtasks completed')) {
|
||||
return { phase: 'coding', message: 'Subtasks marked complete' };
|
||||
}
|
||||
|
||||
// Incomplete build detection - when coordinator exits with pending subtasks
|
||||
if (lowerLog.includes('build incomplete') || lowerLog.includes('subtasks still pending')) {
|
||||
return { phase: 'coding', message: 'Build paused - subtasks still pending' };
|
||||
}
|
||||
|
||||
// Error/failure detection
|
||||
if (lowerLog.includes('build failed') || lowerLog.includes('error:') || lowerLog.includes('fatal')) {
|
||||
return { phase: 'failed', message: log.trim().substring(0, 200) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall progress based on phase and phase progress
|
||||
*/
|
||||
calculateOverallProgress(phase: ExecutionProgressData['phase'], phaseProgress: number): number {
|
||||
// Phase weight ranges (same as in constants.ts)
|
||||
const weights: Record<string, { start: number; end: number }> = {
|
||||
idle: { start: 0, end: 0 },
|
||||
planning: { start: 0, end: 20 },
|
||||
coding: { start: 20, end: 80 },
|
||||
qa_review: { start: 80, end: 95 },
|
||||
qa_fixing: { start: 80, end: 95 },
|
||||
complete: { start: 100, end: 100 },
|
||||
failed: { start: 0, end: 0 }
|
||||
};
|
||||
|
||||
const phaseWeight = weights[phase] || { start: 0, end: 0 };
|
||||
const phaseRange = phaseWeight.end - phaseWeight.start;
|
||||
return Math.round(phaseWeight.start + (phaseRange * phaseProgress / 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ideation progress from log output
|
||||
*/
|
||||
parseIdeationProgress(
|
||||
log: string,
|
||||
currentPhase: string,
|
||||
currentProgress: number,
|
||||
completedTypes: Set<string>,
|
||||
totalTypes: number
|
||||
): { phase: string; progress: number } {
|
||||
let phase = currentPhase;
|
||||
let progress = currentProgress;
|
||||
|
||||
if (log.includes('PROJECT INDEX') || log.includes('PROJECT ANALYSIS')) {
|
||||
phase = 'analyzing';
|
||||
progress = 10;
|
||||
} else if (log.includes('CONTEXT GATHERING')) {
|
||||
phase = 'discovering';
|
||||
progress = 20;
|
||||
} else if (log.includes('GENERATING IDEAS (PARALLEL)') || (log.includes('Starting') && log.includes('ideation agents in parallel'))) {
|
||||
phase = 'generating';
|
||||
progress = 30;
|
||||
} else if (log.includes('MERGE') || log.includes('FINALIZE')) {
|
||||
phase = 'finalizing';
|
||||
progress = 90;
|
||||
} else if (log.includes('IDEATION COMPLETE')) {
|
||||
phase = 'complete';
|
||||
progress = 100;
|
||||
}
|
||||
|
||||
// Update progress based on completed types during generation phase
|
||||
if (phase === 'generating' && completedTypes.size > 0) {
|
||||
// Progress from 30% to 90% based on completed types
|
||||
progress = 30 + Math.floor((completedTypes.size / totalTypes) * 60);
|
||||
}
|
||||
|
||||
return { phase, progress };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse roadmap progress from log output
|
||||
*/
|
||||
parseRoadmapProgress(log: string, currentPhase: string, currentProgress: number): { phase: string; progress: number } {
|
||||
let phase = currentPhase;
|
||||
let progress = currentProgress;
|
||||
|
||||
if (log.includes('PROJECT ANALYSIS')) {
|
||||
phase = 'analyzing';
|
||||
progress = 20;
|
||||
} else if (log.includes('PROJECT DISCOVERY')) {
|
||||
phase = 'discovering';
|
||||
progress = 40;
|
||||
} else if (log.includes('FEATURE GENERATION')) {
|
||||
phase = 'generating';
|
||||
progress = 70;
|
||||
} else if (log.includes('ROADMAP GENERATED')) {
|
||||
phase = 'complete';
|
||||
progress = 100;
|
||||
}
|
||||
|
||||
return { phase, progress };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { AgentQueueManager } from './agent-queue';
|
||||
import {
|
||||
AgentManagerEvents,
|
||||
ExecutionProgressData,
|
||||
ProcessType,
|
||||
SpecCreationMetadata,
|
||||
TaskExecutionOptions,
|
||||
IdeationConfig
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Main AgentManager - orchestrates agent process lifecycle
|
||||
* This is a slim facade that delegates to focused modules
|
||||
*/
|
||||
export class AgentManager extends EventEmitter {
|
||||
private state: AgentState;
|
||||
private events: AgentEvents;
|
||||
private processManager: AgentProcessManager;
|
||||
private queueManager: AgentQueueManager;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Initialize modular components
|
||||
this.state = new AgentState();
|
||||
this.events = new AgentEvents();
|
||||
this.processManager = new AgentProcessManager(this.state, this.events, this);
|
||||
this.queueManager = new AgentQueueManager(this.state, this.events, this.processManager, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure paths for Python and auto-claude source
|
||||
*/
|
||||
configure(pythonPath?: string, autoBuildSourcePath?: string): void {
|
||||
this.processManager.configure(pythonPath, autoBuildSourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start spec creation process
|
||||
*/
|
||||
startSpecCreation(
|
||||
taskId: string,
|
||||
projectPath: string,
|
||||
taskDescription: string,
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata
|
||||
): void {
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const specRunnerPath = path.join(autoBuildSource, 'spec_runner.py');
|
||||
|
||||
if (!existsSync(specRunnerPath)) {
|
||||
this.emit('error', taskId, `Spec runner not found at: ${specRunnerPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
// spec_runner.py will auto-start run.py after spec creation completes
|
||||
const args = [specRunnerPath, '--task', taskDescription, '--project-dir', projectPath];
|
||||
|
||||
// Pass spec directory if provided (for UI-created tasks that already have a directory)
|
||||
if (specDir) {
|
||||
args.push('--spec-dir', specDir);
|
||||
}
|
||||
|
||||
// Check if user requires review before coding
|
||||
if (!metadata?.requireReviewBeforeCoding) {
|
||||
// Auto-approve: When user starts a task from the UI without requiring review
|
||||
args.push('--auto-approve');
|
||||
}
|
||||
|
||||
// Note: This is spec-creation but it chains to task-execution via run.py
|
||||
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start task execution (run.py)
|
||||
*/
|
||||
startTaskExecution(
|
||||
taskId: string,
|
||||
projectPath: string,
|
||||
specId: string,
|
||||
options: TaskExecutionOptions = {}
|
||||
): void {
|
||||
console.log('[AgentManager] startTaskExecution called for:', taskId, specId);
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
console.log('[AgentManager] ERROR: Auto-build source path not found');
|
||||
this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const runPath = path.join(autoBuildSource, 'run.py');
|
||||
console.log('[AgentManager] runPath:', runPath);
|
||||
|
||||
if (!existsSync(runPath)) {
|
||||
console.log('[AgentManager] ERROR: Run script not found at:', runPath);
|
||||
this.emit('error', taskId, `Run script not found at: ${runPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
const args = [runPath, '--spec', specId, '--project-dir', projectPath];
|
||||
|
||||
// Always use auto-continue when running from UI (non-interactive)
|
||||
args.push('--auto-continue');
|
||||
|
||||
// Force: When user starts a task from the UI, that IS their approval
|
||||
args.push('--force');
|
||||
|
||||
// Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent
|
||||
// The options.parallel and options.workers are kept for future use or logging purposes
|
||||
|
||||
console.log('[AgentManager] Spawning process with args:', args);
|
||||
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start QA process
|
||||
*/
|
||||
startQAProcess(
|
||||
taskId: string,
|
||||
projectPath: string,
|
||||
specId: string
|
||||
): void {
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const runPath = path.join(autoBuildSource, 'run.py');
|
||||
|
||||
if (!existsSync(runPath)) {
|
||||
this.emit('error', taskId, `Run script not found at: ${runPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];
|
||||
|
||||
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start roadmap generation process
|
||||
*/
|
||||
startRoadmapGeneration(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
refresh: boolean = false
|
||||
): void {
|
||||
this.queueManager.startRoadmapGeneration(projectId, projectPath, refresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ideation generation process
|
||||
*/
|
||||
startIdeationGeneration(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
config: IdeationConfig,
|
||||
refresh: boolean = false
|
||||
): void {
|
||||
this.queueManager.startIdeationGeneration(projectId, projectPath, config, refresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a specific task's process
|
||||
*/
|
||||
killTask(taskId: string): boolean {
|
||||
return this.processManager.killProcess(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ideation generation for a project
|
||||
*/
|
||||
stopIdeation(projectId: string): boolean {
|
||||
return this.queueManager.stopIdeation(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ideation is running for a project
|
||||
*/
|
||||
isIdeationRunning(projectId: string): boolean {
|
||||
return this.queueManager.isIdeationRunning(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all running processes
|
||||
*/
|
||||
async killAll(): Promise<void> {
|
||||
await this.processManager.killAllProcesses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is running
|
||||
*/
|
||||
isRunning(taskId: string): boolean {
|
||||
return this.state.hasProcess(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running task IDs
|
||||
*/
|
||||
getRunningTasks(): string[] {
|
||||
return this.state.getRunningTaskIds();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { app } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { ProcessType, ExecutionProgressData } from './types';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { projectStore } from '../project-store';
|
||||
|
||||
/**
|
||||
* Process spawning and lifecycle management
|
||||
*/
|
||||
export class AgentProcessManager {
|
||||
private state: AgentState;
|
||||
private events: AgentEvents;
|
||||
private emitter: EventEmitter;
|
||||
private pythonPath: string = 'python3';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
constructor(state: AgentState, events: AgentEvents, emitter: EventEmitter) {
|
||||
this.state = state;
|
||||
this.events = events;
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure paths for Python and auto-claude source
|
||||
*/
|
||||
configure(pythonPath?: string, autoBuildSourcePath?: string): void {
|
||||
if (pythonPath) {
|
||||
this.pythonPath = pythonPath;
|
||||
}
|
||||
if (autoBuildSourcePath) {
|
||||
this.autoBuildSourcePath = autoBuildSourcePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured Python path
|
||||
*/
|
||||
getPythonPath(): string {
|
||||
return this.pythonPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auto-claude source path (detects automatically if not configured)
|
||||
*/
|
||||
getAutoBuildSourcePath(): string | null {
|
||||
// If manually configured, use that
|
||||
if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) {
|
||||
return this.autoBuildSourcePath;
|
||||
}
|
||||
|
||||
// Auto-detect from app location
|
||||
const possiblePaths = [
|
||||
// Dev mode: from dist/main -> ../../auto-claude (sibling to auto-claude-ui)
|
||||
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
|
||||
// Alternative: from app root
|
||||
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
||||
// If running from repo root
|
||||
path.resolve(process.cwd(), 'auto-claude')
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project-specific environment variables based on project settings
|
||||
*/
|
||||
private getProjectEnvVars(projectPath: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Find project by path
|
||||
const projects = projectStore.getProjects();
|
||||
const project = projects.find((p) => p.path === projectPath);
|
||||
|
||||
if (project?.settings) {
|
||||
// Graphiti MCP integration
|
||||
if (project.settings.graphitiMcpEnabled) {
|
||||
const graphitiUrl = project.settings.graphitiMcpUrl || 'http://localhost:8000/mcp/';
|
||||
env['GRAPHITI_MCP_URL'] = graphitiUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load environment variables from auto-claude .env file
|
||||
*/
|
||||
loadAutoBuildEnv(): Record<string, string> {
|
||||
const autoBuildSource = this.getAutoBuildSourcePath();
|
||||
if (!autoBuildSource) {
|
||||
console.log('[loadAutoBuildEnv] No auto-build source path found');
|
||||
return {};
|
||||
}
|
||||
|
||||
const envPath = path.join(autoBuildSource, '.env');
|
||||
console.log('[loadAutoBuildEnv] Looking for .env at:', envPath);
|
||||
if (!existsSync(envPath)) {
|
||||
console.log('[loadAutoBuildEnv] .env file does not exist');
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const envContent = readFileSync(envPath, 'utf-8');
|
||||
const envVars: Record<string, string> = {};
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
// Skip comments and empty lines
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
|
||||
// Remove quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a Python process for task execution
|
||||
*/
|
||||
spawnProcess(
|
||||
taskId: string,
|
||||
cwd: string,
|
||||
args: string[],
|
||||
extraEnv: Record<string, string> = {},
|
||||
processType: ProcessType = 'task-execution'
|
||||
): void {
|
||||
const isSpecRunner = processType === 'spec-creation';
|
||||
// Kill existing process for this task if any
|
||||
this.killProcess(taskId);
|
||||
|
||||
// Generate unique spawn ID for this process instance
|
||||
const spawnId = this.state.generateSpawnId();
|
||||
|
||||
console.log('[spawnProcess] Spawning with pythonPath:', this.pythonPath);
|
||||
console.log('[spawnProcess] cwd:', cwd);
|
||||
console.log('[spawnProcess] processType:', processType);
|
||||
console.log('[spawnProcess] spawnId:', spawnId);
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
const childProcess = spawn(this.pythonPath, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
...profileEnv, // Include active Claude profile config
|
||||
PYTHONUNBUFFERED: '1' // Ensure real-time output
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[spawnProcess] Process spawned, pid:', childProcess.pid);
|
||||
|
||||
this.state.addProcess(taskId, {
|
||||
taskId,
|
||||
process: childProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId
|
||||
});
|
||||
|
||||
// Track execution progress
|
||||
let currentPhase: ExecutionProgressData['phase'] = isSpecRunner ? 'planning' : 'planning';
|
||||
let phaseProgress = 0;
|
||||
let currentSubtask: string | undefined;
|
||||
let lastMessage: string | undefined;
|
||||
// Collect all output for rate limit detection
|
||||
let allOutput = '';
|
||||
|
||||
// Emit initial progress
|
||||
this.emitter.emit('execution-progress', taskId, {
|
||||
phase: currentPhase,
|
||||
phaseProgress: 0,
|
||||
overallProgress: this.events.calculateOverallProgress(currentPhase, 0),
|
||||
message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...'
|
||||
});
|
||||
|
||||
const processLog = (log: string) => {
|
||||
// Collect output for rate limit detection (keep last 10KB)
|
||||
allOutput = (allOutput + log).slice(-10000);
|
||||
// Parse for phase transitions
|
||||
const phaseUpdate = this.events.parseExecutionPhase(log, currentPhase, isSpecRunner);
|
||||
|
||||
if (phaseUpdate) {
|
||||
const phaseChanged = phaseUpdate.phase !== currentPhase;
|
||||
currentPhase = phaseUpdate.phase;
|
||||
|
||||
if (phaseUpdate.currentSubtask) {
|
||||
currentSubtask = phaseUpdate.currentSubtask;
|
||||
}
|
||||
if (phaseUpdate.message) {
|
||||
lastMessage = phaseUpdate.message;
|
||||
}
|
||||
|
||||
// Reset phase progress on phase change, otherwise increment
|
||||
if (phaseChanged) {
|
||||
phaseProgress = 10; // Start new phase at 10%
|
||||
} else {
|
||||
phaseProgress = Math.min(90, phaseProgress + 5); // Increment within phase
|
||||
}
|
||||
|
||||
const overallProgress = this.events.calculateOverallProgress(currentPhase, phaseProgress);
|
||||
|
||||
this.emitter.emit('execution-progress', taskId, {
|
||||
phase: currentPhase,
|
||||
phaseProgress,
|
||||
overallProgress,
|
||||
currentSubtask,
|
||||
message: lastMessage
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle stdout
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const log = data.toString();
|
||||
console.log('[spawnProcess] stdout:', log.substring(0, 200));
|
||||
this.emitter.emit('log', taskId, log);
|
||||
processLog(log);
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const log = data.toString();
|
||||
console.log('[spawnProcess] stderr:', log.substring(0, 200));
|
||||
// Some Python output goes to stderr (like progress bars)
|
||||
// so we treat it as log, not error
|
||||
this.emitter.emit('log', taskId, log);
|
||||
processLog(log);
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
console.log('[spawnProcess] Process exited with code:', code, 'spawnId:', spawnId);
|
||||
this.state.deleteProcess(taskId);
|
||||
|
||||
// Check if this specific spawn was killed (vs exited naturally)
|
||||
// If killed, don't emit exit event to prevent race condition with new process
|
||||
if (this.state.wasSpawnKilled(spawnId)) {
|
||||
console.log('[spawnProcess] Process was killed, skipping exit event for spawnId:', spawnId);
|
||||
this.state.clearKilledSpawn(spawnId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for rate limit if process failed
|
||||
if (code !== 0) {
|
||||
const rateLimitDetection = detectRateLimit(allOutput);
|
||||
if (rateLimitDetection.isRateLimited) {
|
||||
console.log('[spawnProcess] Rate limit detected in task output:', {
|
||||
taskId,
|
||||
resetTime: rateLimitDetection.resetTime,
|
||||
limitType: rateLimitDetection.limitType,
|
||||
suggestedProfile: rateLimitDetection.suggestedProfile?.name
|
||||
});
|
||||
|
||||
// Determine source type based on processType
|
||||
const source = processType === 'spec-creation' ? 'task' : 'task';
|
||||
|
||||
// Emit rate limit event
|
||||
const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, {
|
||||
taskId
|
||||
});
|
||||
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit final progress
|
||||
const finalPhase = code === 0 ? 'complete' : 'failed';
|
||||
this.emitter.emit('execution-progress', taskId, {
|
||||
phase: finalPhase,
|
||||
phaseProgress: 100,
|
||||
overallProgress: code === 0 ? 100 : this.events.calculateOverallProgress(currentPhase, phaseProgress),
|
||||
message: code === 0 ? 'Process completed successfully' : `Process exited with code ${code}`
|
||||
});
|
||||
|
||||
this.emitter.emit('exit', taskId, code, processType);
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
childProcess.on('error', (err: Error) => {
|
||||
console.log('[spawnProcess] Process error:', err.message);
|
||||
this.state.deleteProcess(taskId);
|
||||
|
||||
this.emitter.emit('execution-progress', taskId, {
|
||||
phase: 'failed',
|
||||
phaseProgress: 0,
|
||||
overallProgress: 0,
|
||||
message: `Error: ${err.message}`
|
||||
});
|
||||
|
||||
this.emitter.emit('error', taskId, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a specific task's process
|
||||
*/
|
||||
killProcess(taskId: string): boolean {
|
||||
const agentProcess = this.state.getProcess(taskId);
|
||||
if (agentProcess) {
|
||||
try {
|
||||
// Mark this specific spawn as killed so its exit handler knows to ignore
|
||||
this.state.markSpawnAsKilled(agentProcess.spawnId);
|
||||
|
||||
// Send SIGTERM first for graceful shutdown
|
||||
agentProcess.process.kill('SIGTERM');
|
||||
|
||||
// Force kill after timeout
|
||||
setTimeout(() => {
|
||||
if (!agentProcess.process.killed) {
|
||||
agentProcess.process.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
this.state.deleteProcess(taskId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all running processes
|
||||
*/
|
||||
async killAllProcesses(): Promise<void> {
|
||||
const killPromises = this.state.getRunningTaskIds().map((taskId) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
this.killProcess(taskId);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await Promise.all(killPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get combined environment variables for a project
|
||||
*/
|
||||
getCombinedEnv(projectPath: string): Record<string, string> {
|
||||
const autoBuildEnv = this.loadAutoBuildEnv();
|
||||
const projectEnv = this.getProjectEnvVars(projectPath);
|
||||
return { ...autoBuildEnv, ...projectEnv };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { IdeationConfig } from './types';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
|
||||
/**
|
||||
* Queue management for ideation and roadmap generation
|
||||
*/
|
||||
export class AgentQueueManager {
|
||||
private state: AgentState;
|
||||
private events: AgentEvents;
|
||||
private processManager: AgentProcessManager;
|
||||
private emitter: EventEmitter;
|
||||
|
||||
constructor(
|
||||
state: AgentState,
|
||||
events: AgentEvents,
|
||||
processManager: AgentProcessManager,
|
||||
emitter: EventEmitter
|
||||
) {
|
||||
this.state = state;
|
||||
this.events = events;
|
||||
this.processManager = processManager;
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start roadmap generation process
|
||||
*/
|
||||
startRoadmapGeneration(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
refresh: boolean = false
|
||||
): void {
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
this.emitter.emit('roadmap-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const roadmapRunnerPath = path.join(autoBuildSource, 'roadmap_runner.py');
|
||||
|
||||
if (!existsSync(roadmapRunnerPath)) {
|
||||
this.emitter.emit('roadmap-error', projectId, `Roadmap runner not found at: ${roadmapRunnerPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = [roadmapRunnerPath, '--project', projectPath];
|
||||
|
||||
if (refresh) {
|
||||
args.push('--refresh');
|
||||
}
|
||||
|
||||
// Use projectId as taskId for roadmap operations
|
||||
this.spawnRoadmapProcess(projectId, projectPath, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ideation generation process
|
||||
*/
|
||||
startIdeationGeneration(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
config: IdeationConfig,
|
||||
refresh: boolean = false
|
||||
): void {
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
this.emitter.emit('ideation-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ideationRunnerPath = path.join(autoBuildSource, 'ideation_runner.py');
|
||||
|
||||
if (!existsSync(ideationRunnerPath)) {
|
||||
this.emitter.emit('ideation-error', projectId, `Ideation runner not found at: ${ideationRunnerPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = [ideationRunnerPath, '--project', projectPath];
|
||||
|
||||
// Add enabled types as comma-separated list
|
||||
if (config.enabledTypes.length > 0) {
|
||||
args.push('--types', config.enabledTypes.join(','));
|
||||
}
|
||||
|
||||
// Add context flags (script uses --no-roadmap/--no-kanban negative flags)
|
||||
if (!config.includeRoadmapContext) {
|
||||
args.push('--no-roadmap');
|
||||
}
|
||||
if (!config.includeKanbanContext) {
|
||||
args.push('--no-kanban');
|
||||
}
|
||||
|
||||
// Add max ideas per type
|
||||
if (config.maxIdeasPerType) {
|
||||
args.push('--max-ideas', config.maxIdeasPerType.toString());
|
||||
}
|
||||
|
||||
if (refresh) {
|
||||
args.push('--refresh');
|
||||
}
|
||||
|
||||
// Add append flag to preserve existing ideas
|
||||
if (config.append) {
|
||||
args.push('--append');
|
||||
}
|
||||
|
||||
// Use projectId as taskId for ideation operations
|
||||
this.spawnIdeationProcess(projectId, projectPath, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a Python process for ideation generation
|
||||
*/
|
||||
private spawnIdeationProcess(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
args: string[]
|
||||
): void {
|
||||
// Kill existing process for this project if any
|
||||
this.processManager.killProcess(projectId);
|
||||
|
||||
// Generate unique spawn ID for this process instance
|
||||
const spawnId = this.state.generateSpawnId();
|
||||
|
||||
// Run from auto-claude source directory so imports work correctly
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
const cwd = autoBuildSource || process.cwd();
|
||||
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
// Get Python path from process manager (uses venv if configured)
|
||||
const pythonPath = this.processManager.getPythonPath();
|
||||
|
||||
const childProcess = spawn(pythonPath, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1'
|
||||
}
|
||||
});
|
||||
|
||||
this.state.addProcess(projectId, {
|
||||
taskId: projectId,
|
||||
process: childProcess,
|
||||
startedAt: new Date(),
|
||||
projectPath, // Store project path for loading session on completion
|
||||
spawnId
|
||||
});
|
||||
|
||||
// Track progress through output
|
||||
let progressPhase = 'analyzing';
|
||||
let progressPercent = 10;
|
||||
// Collect output for rate limit detection
|
||||
let allOutput = '';
|
||||
|
||||
// Helper to emit logs - split multi-line output into individual log lines
|
||||
const emitLogs = (log: string) => {
|
||||
const lines = log.split('\n').filter(line => line.trim().length > 0);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 0) {
|
||||
console.log('[Ideation]', trimmed);
|
||||
this.emitter.emit('ideation-log', projectId, trimmed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[Ideation] Starting ideation process with args:', args);
|
||||
console.log('[Ideation] CWD:', cwd);
|
||||
|
||||
// Track completed types for progress calculation
|
||||
const completedTypes = new Set<string>();
|
||||
const totalTypes = 7; // Default all types
|
||||
|
||||
// Handle stdout
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const log = data.toString();
|
||||
// Collect output for rate limit detection (keep last 10KB)
|
||||
allOutput = (allOutput + log).slice(-10000);
|
||||
|
||||
// Emit all log lines for the activity log
|
||||
emitLogs(log);
|
||||
|
||||
// Check for streaming type completion signals
|
||||
const typeCompleteMatch = log.match(/IDEATION_TYPE_COMPLETE:(\w+):(\d+)/);
|
||||
if (typeCompleteMatch) {
|
||||
const [, ideationType, ideasCount] = typeCompleteMatch;
|
||||
completedTypes.add(ideationType);
|
||||
console.log(`[Ideation] Type complete: ${ideationType} with ${ideasCount} ideas`);
|
||||
|
||||
// Emit event for UI to load this type's ideas immediately
|
||||
this.emitter.emit('ideation-type-complete', projectId, ideationType, parseInt(ideasCount, 10));
|
||||
}
|
||||
|
||||
const typeFailedMatch = log.match(/IDEATION_TYPE_FAILED:(\w+)/);
|
||||
if (typeFailedMatch) {
|
||||
const [, ideationType] = typeFailedMatch;
|
||||
completedTypes.add(ideationType);
|
||||
console.log(`[Ideation] Type failed: ${ideationType}`);
|
||||
this.emitter.emit('ideation-type-failed', projectId, ideationType);
|
||||
}
|
||||
|
||||
// Parse progress using AgentEvents
|
||||
const progressUpdate = this.events.parseIdeationProgress(
|
||||
log,
|
||||
progressPhase,
|
||||
progressPercent,
|
||||
completedTypes,
|
||||
totalTypes
|
||||
);
|
||||
progressPhase = progressUpdate.phase;
|
||||
progressPercent = progressUpdate.progress;
|
||||
|
||||
// Emit progress update with a clean message for the status bar
|
||||
const statusMessage = log.trim().split('\n')[0].substring(0, 200);
|
||||
this.emitter.emit('ideation-progress', projectId, {
|
||||
phase: progressPhase,
|
||||
progress: progressPercent,
|
||||
message: statusMessage,
|
||||
completedTypes: Array.from(completedTypes)
|
||||
});
|
||||
});
|
||||
|
||||
// Handle stderr - also emit as logs
|
||||
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const log = data.toString();
|
||||
// Collect stderr for rate limit detection too
|
||||
allOutput = (allOutput + log).slice(-10000);
|
||||
console.error('[Ideation STDERR]', log);
|
||||
emitLogs(log);
|
||||
this.emitter.emit('ideation-progress', projectId, {
|
||||
phase: progressPhase,
|
||||
progress: progressPercent,
|
||||
message: log.trim().split('\n')[0].substring(0, 200)
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
console.log('[Ideation] Process exited with code:', code);
|
||||
|
||||
// Get the stored project path before deleting from map
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const storedProjectPath = processInfo?.projectPath;
|
||||
this.state.deleteProcess(projectId);
|
||||
|
||||
// Check for rate limit if process failed
|
||||
if (code !== 0) {
|
||||
const rateLimitDetection = detectRateLimit(allOutput);
|
||||
if (rateLimitDetection.isRateLimited) {
|
||||
const rateLimitInfo = createSDKRateLimitInfo('ideation', rateLimitDetection, {
|
||||
projectId
|
||||
});
|
||||
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.emitter.emit('ideation-progress', projectId, {
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
message: 'Ideation generation complete'
|
||||
});
|
||||
|
||||
// Load and emit the complete ideation session
|
||||
if (storedProjectPath) {
|
||||
try {
|
||||
const ideationFilePath = path.join(
|
||||
storedProjectPath,
|
||||
'.auto-claude',
|
||||
'ideation',
|
||||
'ideation.json'
|
||||
);
|
||||
if (existsSync(ideationFilePath)) {
|
||||
const content = readFileSync(ideationFilePath, 'utf-8');
|
||||
const session = JSON.parse(content);
|
||||
console.log('[Ideation] Emitting ideation-complete with session data');
|
||||
this.emitter.emit('ideation-complete', projectId, session);
|
||||
} else {
|
||||
console.warn('[Ideation] ideation.json not found at:', ideationFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Ideation] Failed to load ideation session:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.emitter.emit('ideation-error', projectId, `Ideation generation failed with exit code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
childProcess.on('error', (err: Error) => {
|
||||
console.error('[Ideation] Process error:', err.message);
|
||||
this.state.deleteProcess(projectId);
|
||||
this.emitter.emit('ideation-error', projectId, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a Python process for roadmap generation
|
||||
*/
|
||||
private spawnRoadmapProcess(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
args: string[]
|
||||
): void {
|
||||
// Kill existing process for this project if any
|
||||
this.processManager.killProcess(projectId);
|
||||
|
||||
// Generate unique spawn ID for this process instance
|
||||
const spawnId = this.state.generateSpawnId();
|
||||
|
||||
// Run from auto-claude source directory so imports work correctly
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
const cwd = autoBuildSource || process.cwd();
|
||||
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
// Get Python path from process manager (uses venv if configured)
|
||||
const pythonPath = this.processManager.getPythonPath();
|
||||
|
||||
const childProcess = spawn(pythonPath, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1'
|
||||
}
|
||||
});
|
||||
|
||||
this.state.addProcess(projectId, {
|
||||
taskId: projectId,
|
||||
process: childProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId
|
||||
});
|
||||
|
||||
// Track progress through output
|
||||
let progressPhase = 'analyzing';
|
||||
let progressPercent = 10;
|
||||
// Collect output for rate limit detection
|
||||
let allRoadmapOutput = '';
|
||||
|
||||
// Handle stdout
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const log = data.toString();
|
||||
// Collect output for rate limit detection (keep last 10KB)
|
||||
allRoadmapOutput = (allRoadmapOutput + log).slice(-10000);
|
||||
|
||||
// Parse progress using AgentEvents
|
||||
const progressUpdate = this.events.parseRoadmapProgress(log, progressPhase, progressPercent);
|
||||
progressPhase = progressUpdate.phase;
|
||||
progressPercent = progressUpdate.progress;
|
||||
|
||||
// Emit progress update
|
||||
this.emitter.emit('roadmap-progress', projectId, {
|
||||
phase: progressPhase,
|
||||
progress: progressPercent,
|
||||
message: log.trim().substring(0, 200) // Truncate long messages
|
||||
});
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const log = data.toString();
|
||||
// Collect stderr for rate limit detection too
|
||||
allRoadmapOutput = (allRoadmapOutput + log).slice(-10000);
|
||||
this.emitter.emit('roadmap-progress', projectId, {
|
||||
phase: progressPhase,
|
||||
progress: progressPercent,
|
||||
message: log.trim().substring(0, 200)
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
this.state.deleteProcess(projectId);
|
||||
|
||||
// Check for rate limit if process failed
|
||||
if (code !== 0) {
|
||||
const rateLimitDetection = detectRateLimit(allRoadmapOutput);
|
||||
if (rateLimitDetection.isRateLimited) {
|
||||
const rateLimitInfo = createSDKRateLimitInfo('roadmap', rateLimitDetection, {
|
||||
projectId
|
||||
});
|
||||
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.emitter.emit('roadmap-progress', projectId, {
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
message: 'Roadmap generation complete'
|
||||
});
|
||||
} else {
|
||||
this.emitter.emit('roadmap-error', projectId, `Roadmap generation failed with exit code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
childProcess.on('error', (err: Error) => {
|
||||
this.state.deleteProcess(projectId);
|
||||
this.emitter.emit('roadmap-error', projectId, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ideation generation for a project
|
||||
*/
|
||||
stopIdeation(projectId: string): boolean {
|
||||
const wasRunning = this.state.hasProcess(projectId);
|
||||
if (wasRunning) {
|
||||
this.processManager.killProcess(projectId);
|
||||
this.emitter.emit('ideation-stopped', projectId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ideation is running for a project
|
||||
*/
|
||||
isIdeationRunning(projectId: string): boolean {
|
||||
return this.state.hasProcess(projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AgentProcess } from './types';
|
||||
|
||||
/**
|
||||
* Agent state tracking and process map management
|
||||
*/
|
||||
export class AgentState {
|
||||
private processes: Map<string, AgentProcess> = new Map();
|
||||
private killedSpawnIds: Set<number> = new Set();
|
||||
private spawnCounter: number = 0;
|
||||
|
||||
/**
|
||||
* Generate a unique spawn ID
|
||||
*/
|
||||
generateSpawnId(): number {
|
||||
return ++this.spawnCounter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a process to the tracking map
|
||||
*/
|
||||
addProcess(taskId: string, process: AgentProcess): void {
|
||||
this.processes.set(taskId, process);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a process by task ID
|
||||
*/
|
||||
getProcess(taskId: string): AgentProcess | undefined {
|
||||
return this.processes.get(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a process from tracking
|
||||
*/
|
||||
deleteProcess(taskId: string): boolean {
|
||||
return this.processes.delete(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task has a running process
|
||||
*/
|
||||
hasProcess(taskId: string): boolean {
|
||||
return this.processes.has(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running task IDs
|
||||
*/
|
||||
getRunningTaskIds(): string[] {
|
||||
return Array.from(this.processes.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a spawn ID as killed
|
||||
*/
|
||||
markSpawnAsKilled(spawnId: number): void {
|
||||
this.killedSpawnIds.add(spawnId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a spawn ID was killed
|
||||
*/
|
||||
wasSpawnKilled(spawnId: number): boolean {
|
||||
return this.killedSpawnIds.has(spawnId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a spawn ID from killed set
|
||||
*/
|
||||
clearKilledSpawn(spawnId: number): void {
|
||||
this.killedSpawnIds.delete(spawnId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all processes
|
||||
*/
|
||||
getAllProcesses(): Map<string, AgentProcess> {
|
||||
return this.processes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all state (for testing or cleanup)
|
||||
*/
|
||||
clear(): void {
|
||||
this.processes.clear();
|
||||
this.killedSpawnIds.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Agent module - modular agent management system
|
||||
*
|
||||
* This module provides a clean separation of concerns for agent process management:
|
||||
* - AgentManager: Main facade for orchestrating agent lifecycle
|
||||
* - AgentState: Process tracking and state management
|
||||
* - AgentEvents: Event handling and progress parsing
|
||||
* - AgentProcessManager: Process spawning and lifecycle
|
||||
* - AgentQueueManager: Ideation and roadmap queue management
|
||||
*/
|
||||
|
||||
export { AgentManager } from './agent-manager';
|
||||
export { AgentState } from './agent-state';
|
||||
export { AgentEvents } from './agent-events';
|
||||
export { AgentProcessManager } from './agent-process';
|
||||
export { AgentQueueManager } from './agent-queue';
|
||||
|
||||
export type {
|
||||
AgentProcess,
|
||||
ExecutionProgressData,
|
||||
ProcessType,
|
||||
AgentManagerEvents,
|
||||
IdeationConfig,
|
||||
TaskExecutionOptions,
|
||||
SpecCreationMetadata,
|
||||
IdeationProgressData,
|
||||
RoadmapProgressData
|
||||
} from './types';
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
|
||||
/**
|
||||
* Agent-specific types for process and state management
|
||||
*/
|
||||
|
||||
export interface AgentProcess {
|
||||
taskId: string;
|
||||
process: ChildProcess;
|
||||
startedAt: Date;
|
||||
projectPath?: string; // For ideation processes to load session on completion
|
||||
spawnId: number; // Unique ID to identify this specific spawn
|
||||
}
|
||||
|
||||
export interface ExecutionProgressData {
|
||||
phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
|
||||
phaseProgress: number;
|
||||
overallProgress: number;
|
||||
currentSubtask?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process';
|
||||
|
||||
export interface AgentManagerEvents {
|
||||
log: (taskId: string, log: string) => void;
|
||||
error: (taskId: string, error: string) => void;
|
||||
exit: (taskId: string, code: number | null, processType: ProcessType) => void;
|
||||
'execution-progress': (taskId: string, progress: ExecutionProgressData) => void;
|
||||
}
|
||||
|
||||
export interface IdeationConfig {
|
||||
enabledTypes: string[];
|
||||
includeRoadmapContext: boolean;
|
||||
includeKanbanContext: boolean;
|
||||
maxIdeasPerType: number;
|
||||
append?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskExecutionOptions {
|
||||
parallel?: boolean;
|
||||
workers?: number;
|
||||
}
|
||||
|
||||
export interface SpecCreationMetadata {
|
||||
requireReviewBeforeCoding?: boolean;
|
||||
}
|
||||
|
||||
export interface IdeationProgressData {
|
||||
phase: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
completedTypes?: string[];
|
||||
}
|
||||
|
||||
export interface RoadmapProgressData {
|
||||
phase: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
/**
|
||||
* Auto Claude Source Updater
|
||||
*
|
||||
* Checks GitHub for updates to the auto-claude framework and downloads them.
|
||||
* This allows users to get new auto-claude features without requiring a full app update.
|
||||
* Checks GitHub Releases for updates and downloads them.
|
||||
* GitHub Releases are the single source of truth for versioning.
|
||||
*
|
||||
* Update flow:
|
||||
* 1. Check GitHub for latest VERSION file
|
||||
* 2. Compare with bundled source version
|
||||
* 3. If update available, download and replace bundled source
|
||||
* 1. Check GitHub Releases API for the latest release
|
||||
* 2. Compare release tag with current app version
|
||||
* 3. If update available, download release tarball and apply
|
||||
* 4. Existing project update system handles pushing to individual projects
|
||||
*
|
||||
* Versioning:
|
||||
* - Single source of truth: GitHub Releases
|
||||
* - Current version: app.getVersion() (from package.json at build time)
|
||||
* - Latest version: Fetched from GitHub Releases API
|
||||
* - To release: Create a GitHub release with tag (e.g., v1.2.0)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, statSync, copyFileSync } from 'fs';
|
||||
@@ -27,10 +33,25 @@ const execAsync = promisify(exec);
|
||||
const GITHUB_CONFIG = {
|
||||
owner: 'AndyMik90',
|
||||
repo: 'Auto-Claude',
|
||||
branch: 'main',
|
||||
autoBuildPath: 'auto-claude' // Path within repo
|
||||
autoBuildPath: 'auto-claude' // Path within repo where auto-claude lives
|
||||
};
|
||||
|
||||
/**
|
||||
* GitHub Release API response (partial)
|
||||
*/
|
||||
interface GitHubRelease {
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
tarball_url: string;
|
||||
published_at: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
// Cache for the latest release info (used by download)
|
||||
let cachedLatestRelease: GitHubRelease | null = null;
|
||||
|
||||
/**
|
||||
* Result of checking for updates
|
||||
*/
|
||||
@@ -96,35 +117,31 @@ function getUpdateCachePath(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current bundled version
|
||||
* Get the current app/framework version
|
||||
*
|
||||
* Uses app.getVersion() (from package.json) as the single source of truth.
|
||||
* Both the Electron app and auto-claude framework share the same version.
|
||||
*/
|
||||
export function getBundledVersion(): string {
|
||||
const sourcePath = getBundledSourcePath();
|
||||
const versionFile = path.join(sourcePath, 'VERSION');
|
||||
|
||||
if (existsSync(versionFile)) {
|
||||
return readFileSync(versionFile, 'utf-8').trim();
|
||||
}
|
||||
|
||||
return '0.0.0';
|
||||
return app.getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch content from a URL using https
|
||||
* Fetch JSON from a URL using https
|
||||
*/
|
||||
function fetchUrl(url: string): Promise<string> {
|
||||
function fetchJson<T>(url: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/vnd.github.v3.raw'
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
}, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
const redirectUrl = response.headers.location;
|
||||
if (redirectUrl) {
|
||||
fetchUrl(redirectUrl).then(resolve).catch(reject);
|
||||
fetchJson<T>(redirectUrl).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -136,7 +153,13 @@ function fetchUrl(url: string): Promise<string> {
|
||||
|
||||
let data = '';
|
||||
response.on('data', chunk => data += chunk);
|
||||
response.on('end', () => resolve(data));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data) as T);
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to parse JSON response'));
|
||||
}
|
||||
});
|
||||
response.on('error', reject);
|
||||
});
|
||||
|
||||
@@ -149,37 +172,44 @@ function fetchUrl(url: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check GitHub for the latest version
|
||||
* Parse version from GitHub release tag
|
||||
* Handles tags like "v1.2.0", "1.2.0", "v1.2.0-beta"
|
||||
*/
|
||||
function parseVersionFromTag(tag: string): string {
|
||||
// Remove leading 'v' if present
|
||||
return tag.replace(/^v/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check GitHub Releases for the latest version
|
||||
*/
|
||||
export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
const currentVersion = getBundledVersion();
|
||||
|
||||
try {
|
||||
// Fetch VERSION file from GitHub
|
||||
const versionUrl = `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${GITHUB_CONFIG.autoBuildPath}/VERSION`;
|
||||
const latestVersion = (await fetchUrl(versionUrl)).trim();
|
||||
// Fetch latest release from GitHub Releases API
|
||||
const releaseUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`;
|
||||
const release = await fetchJson<GitHubRelease>(releaseUrl);
|
||||
|
||||
// Cache for download function
|
||||
cachedLatestRelease = release;
|
||||
|
||||
// Parse version from tag (e.g., "v1.2.0" -> "1.2.0")
|
||||
const latestVersion = parseVersionFromTag(release.tag_name);
|
||||
|
||||
// Compare versions
|
||||
const updateAvailable = compareVersions(latestVersion, currentVersion) > 0;
|
||||
|
||||
// If update available, try to fetch release notes
|
||||
let releaseNotes: string | undefined;
|
||||
if (updateAvailable) {
|
||||
try {
|
||||
const changelogUrl = `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${GITHUB_CONFIG.autoBuildPath}/CHANGELOG.md`;
|
||||
releaseNotes = await fetchUrl(changelogUrl);
|
||||
} catch {
|
||||
// Changelog is optional
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateAvailable,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
releaseNotes
|
||||
releaseNotes: release.body || undefined
|
||||
};
|
||||
} catch (error) {
|
||||
// Clear cache on error
|
||||
cachedLatestRelease = null;
|
||||
|
||||
return {
|
||||
updateAvailable: false,
|
||||
currentVersion,
|
||||
@@ -276,7 +306,7 @@ function downloadFile(
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and apply the latest auto-claude update
|
||||
* Download and apply the latest auto-claude update from GitHub Releases
|
||||
*
|
||||
* Note: In production, this updates the bundled source in userData.
|
||||
* For packaged apps, we can't modify resourcesPath directly,
|
||||
@@ -290,7 +320,7 @@ export async function downloadAndApplyUpdate(
|
||||
try {
|
||||
onProgress?.({
|
||||
stage: 'checking',
|
||||
message: 'Checking for updates...'
|
||||
message: 'Fetching release info...'
|
||||
});
|
||||
|
||||
// Ensure cache directory exists
|
||||
@@ -298,8 +328,17 @@ export async function downloadAndApplyUpdate(
|
||||
mkdirSync(cachePath, { recursive: true });
|
||||
}
|
||||
|
||||
// Get download URL for the tarball
|
||||
const tarballUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/tarball/${GITHUB_CONFIG.branch}`;
|
||||
// Get release info (use cache or fetch fresh)
|
||||
let release = cachedLatestRelease;
|
||||
if (!release) {
|
||||
const releaseUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`;
|
||||
release = await fetchJson<GitHubRelease>(releaseUrl);
|
||||
cachedLatestRelease = release;
|
||||
}
|
||||
|
||||
// Use the release tarball URL
|
||||
const tarballUrl = release.tarball_url;
|
||||
const releaseVersion = parseVersionFromTag(release.tag_name);
|
||||
|
||||
const tarballPath = path.join(cachePath, 'auto-claude-update.tar.gz');
|
||||
const extractPath = path.join(cachePath, 'extracted');
|
||||
@@ -405,21 +444,22 @@ export async function downloadAndApplyUpdate(
|
||||
copyDirectoryRecursive(autoBuildSource, targetPath, false);
|
||||
}
|
||||
|
||||
// Read the new version
|
||||
const versionFile = path.join(targetPath, 'VERSION');
|
||||
const newVersion = existsSync(versionFile)
|
||||
? readFileSync(versionFile, 'utf-8').trim()
|
||||
: 'unknown';
|
||||
// Use the release version we already have
|
||||
const newVersion = releaseVersion;
|
||||
|
||||
// Write update metadata
|
||||
const metadataPath = path.join(targetPath, '.update-metadata.json');
|
||||
writeFileSync(metadataPath, JSON.stringify({
|
||||
version: newVersion,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'github',
|
||||
branch: GITHUB_CONFIG.branch
|
||||
source: 'github-release',
|
||||
releaseTag: release.tag_name,
|
||||
releaseName: release.name
|
||||
}, null, 2));
|
||||
|
||||
// Clear the cache after successful update
|
||||
cachedLatestRelease = null;
|
||||
|
||||
// Cleanup
|
||||
rmSync(tarballPath, { force: true });
|
||||
rmSync(extractPath, { recursive: true, force: true });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
# Changelog Module
|
||||
|
||||
This directory contains the refactored changelog generation system, split into focused, maintainable modules.
|
||||
|
||||
## Architecture
|
||||
|
||||
The changelog service has been decomposed from a monolithic 1,279-line file into specialized modules:
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
changelog/
|
||||
├── changelog-service.ts # Main orchestrator (slim facade)
|
||||
├── generator.ts # AI-powered changelog generation
|
||||
├── parser.ts # Parsing and extraction logic
|
||||
├── formatter.ts # Prompt building and formatting
|
||||
├── git-integration.ts # Git operations (branches, tags, commits)
|
||||
├── types.ts # Module-specific type definitions
|
||||
└── index.ts # Clean module exports
|
||||
```
|
||||
|
||||
### Responsibilities
|
||||
|
||||
#### `changelog-service.ts` (Main Facade)
|
||||
- Orchestrates all changelog operations
|
||||
- Manages configuration and environment setup
|
||||
- Delegates to specialized modules
|
||||
- Provides public API for IPC handlers
|
||||
- ~465 lines (down from 1,279)
|
||||
|
||||
#### `generator.ts` (AI Generation)
|
||||
- Handles Claude CLI subprocess spawning
|
||||
- Manages generation lifecycle and progress
|
||||
- Rate limit detection and error handling
|
||||
- Environment configuration for subprocess
|
||||
- ~340 lines
|
||||
|
||||
#### `parser.ts` (Parsing & Extraction)
|
||||
- Extract spec overviews
|
||||
- Extract changelog from AI output
|
||||
- Parse existing changelog files
|
||||
- Parse git log output into structured data
|
||||
- ~160 lines
|
||||
|
||||
#### `formatter.ts` (Prompt Building)
|
||||
- Build prompts for task-based changelogs
|
||||
- Build prompts for git-based changelogs
|
||||
- Format templates (keep-a-changelog, simple-list, github-release)
|
||||
- Audience-specific instructions (technical, user-facing, marketing)
|
||||
- Python script generation for Claude CLI
|
||||
- ~190 lines
|
||||
|
||||
#### `git-integration.ts` (Git Operations)
|
||||
- Get branches (local and remote)
|
||||
- Get tags with metadata
|
||||
- Get current and default branch
|
||||
- Get commits for various scenarios (recent, since-date, tag-range)
|
||||
- Get branch diff commits
|
||||
- ~230 lines
|
||||
|
||||
#### `types.ts` (Type Definitions)
|
||||
- Changelog-specific types
|
||||
- Configuration interfaces
|
||||
- Internal type definitions
|
||||
- ~25 lines
|
||||
|
||||
## Usage
|
||||
|
||||
### Import the Service
|
||||
|
||||
```typescript
|
||||
import { changelogService } from './changelog-service';
|
||||
// or
|
||||
import { changelogService } from './changelog';
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
The original `/src/main/changelog-service.ts` now serves as a re-export facade, maintaining full backward compatibility with existing code.
|
||||
|
||||
## Benefits of Refactoring
|
||||
|
||||
1. **Single Responsibility**: Each module has one clear purpose
|
||||
2. **Easier Testing**: Smaller, focused modules are easier to test
|
||||
3. **Better Maintainability**: Changes are isolated to relevant modules
|
||||
4. **Reduced Complexity**: No single file exceeds 500 lines
|
||||
5. **Improved Readability**: Clear separation of concerns
|
||||
6. **Easier Navigation**: Developers can quickly find relevant code
|
||||
|
||||
## Design Patterns Used
|
||||
|
||||
- **Facade Pattern**: `changelog-service.ts` provides unified interface
|
||||
- **Delegation**: Service delegates to specialized modules
|
||||
- **Single Responsibility**: Each module has one clear concern
|
||||
- **Event Emitter**: Generator emits progress and error events
|
||||
|
||||
## Migration Notes
|
||||
|
||||
No changes required for existing code - the refactoring maintains full API compatibility through the facade pattern.
|
||||
@@ -0,0 +1,479 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { app } from 'electron';
|
||||
import { AUTO_BUILD_PATHS, DEFAULT_CHANGELOG_PATH } from '../../shared/constants';
|
||||
import type {
|
||||
ChangelogTask,
|
||||
TaskSpecContent,
|
||||
ChangelogGenerationRequest,
|
||||
ChangelogSaveRequest,
|
||||
ChangelogSaveResult,
|
||||
ExistingChangelog,
|
||||
Task,
|
||||
ImplementationPlan,
|
||||
GitBranchInfo,
|
||||
GitTagInfo
|
||||
} from '../../shared/types';
|
||||
import { ChangelogGenerator } from './generator';
|
||||
import { parseExistingChangelog } from './parser';
|
||||
import {
|
||||
getBranches,
|
||||
getTags,
|
||||
getCurrentBranch,
|
||||
getDefaultBranch,
|
||||
getCommits,
|
||||
getBranchDiffCommits
|
||||
} from './git-integration';
|
||||
|
||||
/**
|
||||
* Main changelog service - orchestrates all changelog operations
|
||||
* Delegates to specialized modules for specific concerns
|
||||
*/
|
||||
export class ChangelogService extends EventEmitter {
|
||||
private pythonPath: string = 'python3';
|
||||
private claudePath: string = 'claude';
|
||||
private autoBuildSourcePath: string = '';
|
||||
private cachedEnv: Record<string, string> | null = null;
|
||||
private debugEnabled: boolean | null = null;
|
||||
private generator: ChangelogGenerator | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.detectClaudePath();
|
||||
this.debug('ChangelogService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the full path to the claude CLI
|
||||
* Electron apps don't inherit shell PATH, so we need to find it explicitly
|
||||
*/
|
||||
private detectClaudePath(): void {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// Platform-specific possible paths
|
||||
const possiblePaths = process.platform === 'win32'
|
||||
? [
|
||||
// Windows paths
|
||||
path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'),
|
||||
path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'),
|
||||
path.join(homeDir, '.local', 'bin', 'claude.exe'),
|
||||
'C:\\Program Files\\Claude\\claude.exe',
|
||||
'C:\\Program Files (x86)\\Claude\\claude.exe',
|
||||
// Also check if claude is in system PATH
|
||||
'claude'
|
||||
]
|
||||
: [
|
||||
// Unix paths (macOS/Linux)
|
||||
'/usr/local/bin/claude',
|
||||
'/opt/homebrew/bin/claude',
|
||||
path.join(homeDir, '.local/bin/claude'),
|
||||
path.join(homeDir, 'bin/claude'),
|
||||
// Also check if claude is in system PATH
|
||||
'claude'
|
||||
];
|
||||
|
||||
for (const claudePath of possiblePaths) {
|
||||
if (claudePath === 'claude' || existsSync(claudePath)) {
|
||||
this.claudePath = claudePath;
|
||||
this.debug('Claude CLI found at:', claudePath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.debug('Claude CLI not found in common locations, using default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
* Checks DEBUG from auto-claude/.env and AUTO_CLAUDE_DEBUG from process.env
|
||||
*/
|
||||
private isDebugEnabled(): boolean {
|
||||
// Cache the result after first check
|
||||
if (this.debugEnabled !== null) {
|
||||
return this.debugEnabled;
|
||||
}
|
||||
|
||||
// Check process.env first
|
||||
if (
|
||||
process.env.DEBUG === 'true' ||
|
||||
process.env.DEBUG === '1' ||
|
||||
process.env.AUTO_CLAUDE_DEBUG === 'true' ||
|
||||
process.env.AUTO_CLAUDE_DEBUG === '1'
|
||||
) {
|
||||
this.debugEnabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check auto-claude .env file
|
||||
const env = this.loadAutoBuildEnv();
|
||||
this.debugEnabled = env.DEBUG === 'true' || env.DEBUG === '1';
|
||||
return this.debugEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when DEBUG=true in auto-claude/.env or AUTO_CLAUDE_DEBUG is set
|
||||
*/
|
||||
private debug(...args: unknown[]): void {
|
||||
if (this.isDebugEnabled()) {
|
||||
console.log('[ChangelogService]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure paths for Python and auto-claude source
|
||||
*/
|
||||
configure(pythonPath?: string, autoBuildSourcePath?: string): void {
|
||||
if (pythonPath) {
|
||||
this.pythonPath = pythonPath;
|
||||
}
|
||||
if (autoBuildSourcePath) {
|
||||
this.autoBuildSourcePath = autoBuildSourcePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auto-claude source path (detects automatically if not configured)
|
||||
*/
|
||||
private getAutoBuildSourcePath(): string | null {
|
||||
if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) {
|
||||
return this.autoBuildSourcePath;
|
||||
}
|
||||
|
||||
const possiblePaths = [
|
||||
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
|
||||
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
||||
path.resolve(process.cwd(), 'auto-claude')
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load environment variables from auto-claude .env file
|
||||
*/
|
||||
private loadAutoBuildEnv(): Record<string, string> {
|
||||
const autoBuildSource = this.getAutoBuildSourcePath();
|
||||
if (!autoBuildSource) return {};
|
||||
|
||||
const envPath = path.join(autoBuildSource, '.env');
|
||||
if (!existsSync(envPath)) return {};
|
||||
|
||||
try {
|
||||
const envContent = readFileSync(envPath, 'utf-8');
|
||||
const envVars: Record<string, string> = {};
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the generator instance
|
||||
*/
|
||||
private getGenerator(): ChangelogGenerator {
|
||||
if (!this.generator) {
|
||||
const autoBuildSource = this.getAutoBuildSourcePath();
|
||||
if (!autoBuildSource) {
|
||||
throw new Error('Auto-build source path not found');
|
||||
}
|
||||
|
||||
// Verify claude CLI is available
|
||||
if (this.claudePath !== 'claude' && !existsSync(this.claudePath)) {
|
||||
throw new Error(`Claude CLI not found. Please ensure Claude Code is installed. Looked for: ${this.claudePath}`);
|
||||
}
|
||||
|
||||
const autoBuildEnv = this.loadAutoBuildEnv();
|
||||
|
||||
this.generator = new ChangelogGenerator(
|
||||
this.pythonPath,
|
||||
this.claudePath,
|
||||
autoBuildSource,
|
||||
autoBuildEnv,
|
||||
this.isDebugEnabled()
|
||||
);
|
||||
|
||||
// Forward events from generator
|
||||
this.generator.on('generation-complete', (projectId, result) => {
|
||||
this.emit('generation-complete', projectId, result);
|
||||
});
|
||||
|
||||
this.generator.on('generation-progress', (projectId, progress) => {
|
||||
this.emit('generation-progress', projectId, progress);
|
||||
});
|
||||
|
||||
this.generator.on('generation-error', (projectId, error) => {
|
||||
this.emit('generation-error', projectId, error);
|
||||
});
|
||||
|
||||
this.generator.on('rate-limit', (projectId, rateLimitInfo) => {
|
||||
this.emit('rate-limit', projectId, rateLimitInfo);
|
||||
});
|
||||
}
|
||||
|
||||
return this.generator;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Task Management
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get completed tasks from a project
|
||||
*/
|
||||
getCompletedTasks(projectPath: string, tasks: Task[], specsBaseDir?: string): ChangelogTask[] {
|
||||
const specsDir = path.join(projectPath, specsBaseDir || AUTO_BUILD_PATHS.SPECS_DIR);
|
||||
|
||||
return tasks
|
||||
.filter(task => task.status === 'done' && !task.metadata?.archivedAt)
|
||||
.map(task => {
|
||||
const specDir = path.join(specsDir, task.specId);
|
||||
const hasSpecs = existsSync(specDir) && existsSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE));
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
specId: task.specId,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
completedAt: task.updatedAt,
|
||||
hasSpecs
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Load spec files for given tasks
|
||||
*/
|
||||
async loadTaskSpecs(projectPath: string, taskIds: string[], tasks: Task[], specsBaseDir?: string): Promise<TaskSpecContent[]> {
|
||||
const specsDir = path.join(projectPath, specsBaseDir || AUTO_BUILD_PATHS.SPECS_DIR);
|
||||
this.debug('loadTaskSpecs called', { projectPath, specsDir, taskCount: taskIds.length });
|
||||
|
||||
const results: TaskSpecContent[] = [];
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (!task) {
|
||||
this.debug('Task not found:', taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const specDir = path.join(specsDir, task.specId);
|
||||
this.debug('Loading spec for task', { taskId, specId: task.specId, specDir });
|
||||
|
||||
const content: TaskSpecContent = {
|
||||
taskId,
|
||||
specId: task.specId
|
||||
};
|
||||
|
||||
try {
|
||||
// Load spec.md
|
||||
const specPath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE);
|
||||
if (existsSync(specPath)) {
|
||||
content.spec = readFileSync(specPath, 'utf-8');
|
||||
this.debug('Loaded spec.md', { specId: task.specId, length: content.spec.length });
|
||||
}
|
||||
|
||||
// Load requirements.json
|
||||
const requirementsPath = path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS);
|
||||
if (existsSync(requirementsPath)) {
|
||||
content.requirements = JSON.parse(readFileSync(requirementsPath, 'utf-8'));
|
||||
}
|
||||
|
||||
// Load qa_report.md
|
||||
const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT);
|
||||
if (existsSync(qaReportPath)) {
|
||||
content.qaReport = readFileSync(qaReportPath, 'utf-8');
|
||||
}
|
||||
|
||||
// Load implementation_plan.json
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
if (existsSync(planPath)) {
|
||||
content.implementationPlan = JSON.parse(readFileSync(planPath, 'utf-8')) as ImplementationPlan;
|
||||
}
|
||||
} catch (error) {
|
||||
content.error = error instanceof Error ? error.message : 'Failed to load spec files';
|
||||
this.debug('Error loading spec', { specId: task.specId, error: content.error });
|
||||
}
|
||||
|
||||
results.push(content);
|
||||
}
|
||||
|
||||
this.debug('loadTaskSpecs complete', { loadedCount: results.length });
|
||||
return results;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Git Data Retrieval
|
||||
// ============================================
|
||||
|
||||
getBranches(projectPath: string): GitBranchInfo[] {
|
||||
return getBranches(projectPath, this.isDebugEnabled());
|
||||
}
|
||||
|
||||
getTags(projectPath: string): GitTagInfo[] {
|
||||
return getTags(projectPath, this.isDebugEnabled());
|
||||
}
|
||||
|
||||
getCurrentBranch(projectPath: string): string {
|
||||
return getCurrentBranch(projectPath);
|
||||
}
|
||||
|
||||
getDefaultBranch(projectPath: string): string {
|
||||
return getDefaultBranch(projectPath);
|
||||
}
|
||||
|
||||
getCommits(projectPath: string, options: import('../../shared/types').GitHistoryOptions): import('../../shared/types').GitCommit[] {
|
||||
return getCommits(projectPath, options, this.isDebugEnabled());
|
||||
}
|
||||
|
||||
getBranchDiffCommits(projectPath: string, options: import('../../shared/types').BranchDiffOptions): import('../../shared/types').GitCommit[] {
|
||||
return getBranchDiffCommits(projectPath, options, this.isDebugEnabled());
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Changelog Generation
|
||||
// ============================================
|
||||
|
||||
generateChangelog(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
request: ChangelogGenerationRequest,
|
||||
specs?: TaskSpecContent[]
|
||||
): void {
|
||||
try {
|
||||
const generator = this.getGenerator();
|
||||
generator.generate(projectId, projectPath, request, specs);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize generator';
|
||||
this.debug('ERROR:', errorMessage);
|
||||
this.emit('generation-error', projectId, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
cancelGeneration(projectId: string): boolean {
|
||||
if (this.generator) {
|
||||
return this.generator.cancel(projectId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// File Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Save changelog to file
|
||||
*/
|
||||
saveChangelog(
|
||||
projectPath: string,
|
||||
request: ChangelogSaveRequest
|
||||
): ChangelogSaveResult {
|
||||
const filePath = request.filePath
|
||||
? path.join(projectPath, request.filePath)
|
||||
: path.join(projectPath, DEFAULT_CHANGELOG_PATH);
|
||||
|
||||
let finalContent = request.content;
|
||||
|
||||
if (request.mode === 'prepend' && existsSync(filePath)) {
|
||||
const existing = readFileSync(filePath, 'utf-8');
|
||||
// Add separator between new and existing content
|
||||
finalContent = `${request.content}\n\n${existing}`;
|
||||
} else if (request.mode === 'append' && existsSync(filePath)) {
|
||||
const existing = readFileSync(filePath, 'utf-8');
|
||||
finalContent = `${existing}\n\n${request.content}`;
|
||||
}
|
||||
|
||||
writeFileSync(filePath, finalContent, 'utf-8');
|
||||
|
||||
return {
|
||||
filePath,
|
||||
bytesWritten: Buffer.byteLength(finalContent, 'utf-8')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read existing changelog file
|
||||
*/
|
||||
readExistingChangelog(projectPath: string): ExistingChangelog {
|
||||
const filePath = path.join(projectPath, DEFAULT_CHANGELOG_PATH);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
return parseExistingChangelog(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest next version based on task types
|
||||
*/
|
||||
suggestVersion(specs: TaskSpecContent[], currentVersion?: string): string {
|
||||
// Default starting version
|
||||
if (!currentVersion) {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
let [major, minor, patch] = parts;
|
||||
|
||||
// Analyze specs for version increment decision
|
||||
let hasBreakingChanges = false;
|
||||
let hasNewFeatures = false;
|
||||
|
||||
for (const spec of specs) {
|
||||
const content = (spec.spec || '').toLowerCase();
|
||||
|
||||
if (content.includes('breaking change') || content.includes('breaking:')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
|
||||
if (spec.implementationPlan?.workflow_type === 'new_feature' ||
|
||||
content.includes('new feature') ||
|
||||
content.includes('## added')) {
|
||||
hasNewFeatures = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBreakingChanges) {
|
||||
return `${major + 1}.0.0`;
|
||||
} else if (hasNewFeatures) {
|
||||
return `${major}.${minor + 1}.0`;
|
||||
} else {
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const changelogService = new ChangelogService();
|
||||
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
ChangelogGenerationRequest,
|
||||
TaskSpecContent,
|
||||
GitCommit
|
||||
} from '../../shared/types';
|
||||
import { extractSpecOverview } from './parser';
|
||||
|
||||
/**
|
||||
* Format instructions for different changelog styles
|
||||
*/
|
||||
const FORMAT_TEMPLATES = {
|
||||
'keep-a-changelog': (version: string, date: string) => `## [${version}] - ${date}
|
||||
|
||||
### Added
|
||||
- [New features]
|
||||
|
||||
### Changed
|
||||
- [Modifications]
|
||||
|
||||
### Fixed
|
||||
- [Bug fixes]`,
|
||||
|
||||
'simple-list': (version: string, date: string) => `# Release v${version} (${date})
|
||||
|
||||
**New Features:**
|
||||
- [List features]
|
||||
|
||||
**Improvements:**
|
||||
- [List improvements]
|
||||
|
||||
**Bug Fixes:**
|
||||
- [List fixes]`,
|
||||
|
||||
'github-release': (version: string) => `## What's New in v${version}
|
||||
|
||||
### New Features
|
||||
- **Feature Name**: Description
|
||||
|
||||
### Improvements
|
||||
- Description
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed [issue]`
|
||||
};
|
||||
|
||||
/**
|
||||
* Audience-specific writing instructions
|
||||
*/
|
||||
const AUDIENCE_INSTRUCTIONS = {
|
||||
'technical': 'You are a technical documentation specialist creating a changelog for developers. Use precise technical language.',
|
||||
'user-facing': 'You are a product manager writing release notes for end users. Use clear, non-technical language focusing on user benefits.',
|
||||
'marketing': 'You are a marketing specialist writing release notes. Focus on outcomes and user impact with compelling language.'
|
||||
};
|
||||
|
||||
/**
|
||||
* Build changelog prompt from task specs
|
||||
*/
|
||||
export function buildChangelogPrompt(
|
||||
request: ChangelogGenerationRequest,
|
||||
specs: TaskSpecContent[]
|
||||
): string {
|
||||
const audienceInstruction = AUDIENCE_INSTRUCTIONS[request.audience];
|
||||
const formatInstruction = FORMAT_TEMPLATES[request.format](request.version, request.date);
|
||||
|
||||
// Build CONCISE task summaries (key to avoiding timeout)
|
||||
const taskSummaries = specs.map(spec => {
|
||||
const parts: string[] = [`- **${spec.specId}**`];
|
||||
|
||||
// Get workflow type if available
|
||||
if (spec.implementationPlan?.workflow_type) {
|
||||
parts.push(`(${spec.implementationPlan.workflow_type})`);
|
||||
}
|
||||
|
||||
// Extract just the overview/purpose
|
||||
if (spec.spec) {
|
||||
const overview = extractSpecOverview(spec.spec);
|
||||
if (overview) {
|
||||
parts.push(`: ${overview}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}).join('\n');
|
||||
|
||||
return `${audienceInstruction}
|
||||
|
||||
Format:
|
||||
${formatInstruction}
|
||||
|
||||
Completed tasks:
|
||||
${taskSummaries}
|
||||
|
||||
${request.customInstructions ? `Note: ${request.customInstructions}` : ''}
|
||||
|
||||
CRITICAL: Output ONLY the raw changelog content. Do NOT include ANY introductory text, analysis, or explanation. Start directly with the changelog heading (## or #). No "Here's the changelog" or similar phrases.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build changelog prompt from git commits
|
||||
*/
|
||||
export function buildGitPrompt(
|
||||
request: ChangelogGenerationRequest,
|
||||
commits: GitCommit[]
|
||||
): string {
|
||||
const audienceInstruction = AUDIENCE_INSTRUCTIONS[request.audience];
|
||||
const formatInstruction = FORMAT_TEMPLATES[request.format](request.version, request.date);
|
||||
|
||||
// Format commits for the prompt
|
||||
// Group by conventional commit type if detected
|
||||
const commitLines = commits.map(commit => {
|
||||
const hash = commit.hash;
|
||||
const subject = commit.subject;
|
||||
// Detect conventional commit format: type(scope): message
|
||||
const conventionalMatch = subject.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/);
|
||||
if (conventionalMatch) {
|
||||
const [, type, scope, message] = conventionalMatch;
|
||||
return `- ${hash}: [${type}${scope ? `/${scope}` : ''}] ${message}`;
|
||||
}
|
||||
return `- ${hash}: ${subject}`;
|
||||
}).join('\n');
|
||||
|
||||
// Add context about branch/range if available
|
||||
let sourceContext = '';
|
||||
if (request.branchDiff) {
|
||||
sourceContext = `These commits are from branch "${request.branchDiff.compareBranch}" that are not in "${request.branchDiff.baseBranch}".`;
|
||||
} else if (request.gitHistory) {
|
||||
switch (request.gitHistory.type) {
|
||||
case 'recent':
|
||||
sourceContext = `These are the ${commits.length} most recent commits.`;
|
||||
break;
|
||||
case 'since-date':
|
||||
sourceContext = `These are commits since ${request.gitHistory.sinceDate}.`;
|
||||
break;
|
||||
case 'tag-range':
|
||||
sourceContext = `These are commits between tag "${request.gitHistory.fromTag}" and "${request.gitHistory.toTag || 'HEAD'}".`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${audienceInstruction}
|
||||
|
||||
${sourceContext}
|
||||
|
||||
Generate a changelog from these git commits. Group related changes together and categorize them appropriately.
|
||||
|
||||
Conventional commit types to recognize:
|
||||
- feat/feature: New features → Added section
|
||||
- fix/bugfix: Bug fixes → Fixed section
|
||||
- docs: Documentation → Changed or separate Documentation section
|
||||
- style: Styling/formatting → Changed section
|
||||
- refactor: Code refactoring → Changed section
|
||||
- perf: Performance → Changed or Performance section
|
||||
- test: Tests → (usually omit unless significant)
|
||||
- chore: Maintenance → (usually omit unless significant)
|
||||
|
||||
Format:
|
||||
${formatInstruction}
|
||||
|
||||
Git commits (${commits.length} total):
|
||||
${commitLines}
|
||||
|
||||
${request.customInstructions ? `Note: ${request.customInstructions}` : ''}
|
||||
|
||||
CRITICAL: Output ONLY the raw changelog content. Do NOT include ANY introductory text, analysis, or explanation. Start directly with the changelog heading (## or #). No "Here's the changelog" or similar phrases. Intelligently group and summarize related commits - don't just list each commit individually.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Python script for Claude generation
|
||||
*/
|
||||
export function createGenerationScript(prompt: string, claudePath: string): string {
|
||||
// Convert prompt to base64 to avoid any string escaping issues in Python
|
||||
const base64Prompt = Buffer.from(prompt, 'utf-8').toString('base64');
|
||||
|
||||
// Escape the claude path for Python string
|
||||
const escapedClaudePath = claudePath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
|
||||
return `
|
||||
import subprocess
|
||||
import sys
|
||||
import base64
|
||||
|
||||
try:
|
||||
# Decode the base64 prompt to avoid string escaping issues
|
||||
prompt = base64.b64decode('${base64Prompt}').decode('utf-8')
|
||||
|
||||
# Use Claude Code CLI to generate
|
||||
# stdin=DEVNULL prevents hanging when claude checks for interactive input
|
||||
result = subprocess.run(
|
||||
['${escapedClaudePath}', '-p', prompt, '--output-format', 'text', '--model', 'haiku'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(result.stdout)
|
||||
else:
|
||||
# Print more detailed error info
|
||||
print(f"Claude CLI error (code {result.returncode}):", file=sys.stderr)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(f"stdout: {result.stdout}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Python error: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type {
|
||||
ChangelogGenerationRequest,
|
||||
ChangelogGenerationResult,
|
||||
ChangelogGenerationProgress,
|
||||
TaskSpecContent
|
||||
} from '../../shared/types';
|
||||
import { buildChangelogPrompt, buildGitPrompt, createGenerationScript } from './formatter';
|
||||
import { extractChangelog } from './parser';
|
||||
import { getCommits, getBranchDiffCommits } from './git-integration';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
|
||||
/**
|
||||
* Core changelog generation logic
|
||||
* Handles AI generation via Claude CLI subprocess
|
||||
*/
|
||||
export class ChangelogGenerator extends EventEmitter {
|
||||
private generationProcesses: Map<string, ReturnType<typeof spawn>> = new Map();
|
||||
private debugEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
private pythonPath: string,
|
||||
private claudePath: string,
|
||||
private autoBuildSourcePath: string,
|
||||
private autoBuildEnv: Record<string, string>,
|
||||
debugEnabled: boolean
|
||||
) {
|
||||
super();
|
||||
this.debugEnabled = debugEnabled;
|
||||
}
|
||||
|
||||
private debug(...args: unknown[]): void {
|
||||
if (this.debugEnabled) {
|
||||
console.log('[ChangelogGenerator]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate changelog using Claude AI
|
||||
* Supports multiple source modes: tasks (specs), git-history, or branch-diff
|
||||
*/
|
||||
async generate(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
request: ChangelogGenerationRequest,
|
||||
specs?: TaskSpecContent[]
|
||||
): Promise<void> {
|
||||
const sourceMode = request.sourceMode || 'tasks';
|
||||
|
||||
this.debug('generate called', {
|
||||
projectId,
|
||||
projectPath,
|
||||
sourceMode,
|
||||
taskCount: request.taskIds?.length || 0,
|
||||
version: request.version,
|
||||
format: request.format,
|
||||
audience: request.audience
|
||||
});
|
||||
|
||||
// Kill existing process if any
|
||||
this.cancel(projectId);
|
||||
|
||||
let prompt: string;
|
||||
let itemCount: number;
|
||||
|
||||
// Handle different source modes
|
||||
if (sourceMode === 'git-history' && request.gitHistory) {
|
||||
// Git history mode
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'loading_commits',
|
||||
progress: 10,
|
||||
message: 'Loading commits from git history...'
|
||||
});
|
||||
|
||||
const commits = getCommits(projectPath, request.gitHistory, this.debugEnabled);
|
||||
if (commits.length === 0) {
|
||||
this.emitError(projectId, 'No commits found for the specified range');
|
||||
return;
|
||||
}
|
||||
|
||||
prompt = buildGitPrompt(request, commits);
|
||||
itemCount = commits.length;
|
||||
|
||||
} else if (sourceMode === 'branch-diff' && request.branchDiff) {
|
||||
// Branch diff mode
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'loading_commits',
|
||||
progress: 10,
|
||||
message: `Loading commits between ${request.branchDiff.baseBranch} and ${request.branchDiff.compareBranch}...`
|
||||
});
|
||||
|
||||
const commits = getBranchDiffCommits(projectPath, request.branchDiff, this.debugEnabled);
|
||||
if (commits.length === 0) {
|
||||
this.emitError(projectId, 'No commits found between the specified branches');
|
||||
return;
|
||||
}
|
||||
|
||||
prompt = buildGitPrompt(request, commits);
|
||||
itemCount = commits.length;
|
||||
|
||||
} else {
|
||||
// Tasks mode (original behavior)
|
||||
if (!specs || specs.length === 0) {
|
||||
this.emitError(projectId, 'No specs provided for changelog generation');
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'loading_specs',
|
||||
progress: 10,
|
||||
message: 'Preparing changelog generation...'
|
||||
});
|
||||
|
||||
prompt = buildChangelogPrompt(request, specs);
|
||||
itemCount = specs.length;
|
||||
}
|
||||
|
||||
this.debug('Prompt built', {
|
||||
promptLength: prompt.length,
|
||||
promptPreview: prompt.substring(0, 500) + '...'
|
||||
});
|
||||
|
||||
// Create Python script
|
||||
const script = createGenerationScript(prompt, this.claudePath);
|
||||
this.debug('Python script created', { scriptLength: script.length });
|
||||
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'generating',
|
||||
progress: 30,
|
||||
message: 'Generating changelog with Claude AI...'
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
this.debug('Spawning Python process...');
|
||||
|
||||
// Build environment with explicit critical variables
|
||||
const spawnEnv = this.buildSpawnEnvironment();
|
||||
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
cwd: this.autoBuildSourcePath,
|
||||
env: spawnEnv
|
||||
});
|
||||
|
||||
this.generationProcesses.set(projectId, childProcess);
|
||||
this.debug('Process spawned with PID:', childProcess.pid);
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
output += chunk;
|
||||
this.debug('stdout chunk received', { chunkLength: chunk.length, totalOutput: output.length });
|
||||
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'generating',
|
||||
progress: 50,
|
||||
message: 'Generating changelog content...'
|
||||
});
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
errorOutput += chunk;
|
||||
this.debug('stderr chunk received', { chunk: chunk.substring(0, 200) });
|
||||
});
|
||||
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
const duration = Date.now() - startTime;
|
||||
this.debug('Process exited', {
|
||||
code,
|
||||
duration: `${duration}ms`,
|
||||
outputLength: output.length,
|
||||
errorLength: errorOutput.length
|
||||
});
|
||||
|
||||
this.generationProcesses.delete(projectId);
|
||||
|
||||
if (code === 0 && output.trim()) {
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'formatting',
|
||||
progress: 90,
|
||||
message: 'Formatting changelog...'
|
||||
});
|
||||
|
||||
// Extract changelog from output
|
||||
const changelog = extractChangelog(output.trim());
|
||||
this.debug('Changelog extracted', { changelogLength: changelog.length });
|
||||
|
||||
this.emitProgress(projectId, {
|
||||
stage: 'complete',
|
||||
progress: 100,
|
||||
message: 'Changelog generation complete'
|
||||
});
|
||||
|
||||
const result: ChangelogGenerationResult = {
|
||||
success: true,
|
||||
changelog,
|
||||
version: request.version,
|
||||
tasksIncluded: itemCount
|
||||
};
|
||||
|
||||
this.debug('Generation complete, emitting result');
|
||||
this.emit('generation-complete', projectId, result);
|
||||
} else {
|
||||
// Combine all output for error analysis
|
||||
const combinedOutput = `${output}\n${errorOutput}`;
|
||||
const error = errorOutput || `Generation failed with exit code ${code}`;
|
||||
|
||||
// Check for rate limit
|
||||
const rateLimitDetection = detectRateLimit(combinedOutput);
|
||||
if (rateLimitDetection.isRateLimited) {
|
||||
this.debug('Rate limit detected in changelog generation', {
|
||||
resetTime: rateLimitDetection.resetTime,
|
||||
limitType: rateLimitDetection.limitType,
|
||||
suggestedProfile: rateLimitDetection.suggestedProfile?.name
|
||||
});
|
||||
|
||||
// Emit rate limit event
|
||||
const rateLimitInfo = createSDKRateLimitInfo('changelog', rateLimitDetection, { projectId });
|
||||
this.emit('rate-limit', projectId, rateLimitInfo);
|
||||
}
|
||||
|
||||
this.debug('Generation failed', { error: error.substring(0, 500), isRateLimited: rateLimitDetection.isRateLimited });
|
||||
this.emitError(projectId, error);
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (err: Error) => {
|
||||
this.debug('Process error', { error: err.message });
|
||||
this.generationProcesses.delete(projectId);
|
||||
this.emitError(projectId, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build spawn environment with proper PATH and auth settings
|
||||
*/
|
||||
private buildSpawnEnvironment(): Record<string, string> {
|
||||
const homeDir = os.homedir();
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Build PATH with platform-appropriate separator and locations
|
||||
const pathAdditions = isWindows
|
||||
? [
|
||||
path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude'),
|
||||
path.join(homeDir, 'AppData', 'Roaming', 'npm'),
|
||||
path.join(homeDir, '.local', 'bin'),
|
||||
'C:\\Program Files\\Claude',
|
||||
'C:\\Program Files (x86)\\Claude'
|
||||
]
|
||||
: [
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
path.join(homeDir, '.local', 'bin'),
|
||||
path.join(homeDir, 'bin')
|
||||
];
|
||||
|
||||
// Get active Claude profile environment (OAuth token preferred, falls back to CLAUDE_CONFIG_DIR)
|
||||
const profileEnv = getProfileEnv();
|
||||
this.debug('Active profile environment', {
|
||||
hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR,
|
||||
authMethod: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (profileEnv.CLAUDE_CONFIG_DIR ? 'config-dir' : 'default')
|
||||
});
|
||||
|
||||
const spawnEnv: Record<string, string> = {
|
||||
...process.env as Record<string, string>,
|
||||
...this.autoBuildEnv,
|
||||
...profileEnv, // Include active Claude profile config
|
||||
// Ensure critical env vars are set for claude CLI
|
||||
// Use USERPROFILE on Windows, HOME on Unix
|
||||
...(isWindows ? { USERPROFILE: homeDir } : { HOME: homeDir }),
|
||||
USER: process.env.USER || process.env.USERNAME || 'user',
|
||||
// Add common binary locations to PATH for claude CLI
|
||||
PATH: [process.env.PATH || '', ...pathAdditions].filter(Boolean).join(path.delimiter),
|
||||
PYTHONUNBUFFERED: '1'
|
||||
};
|
||||
|
||||
this.debug('Spawn environment', {
|
||||
HOME: spawnEnv.HOME,
|
||||
USER: spawnEnv.USER,
|
||||
pathDirs: spawnEnv.PATH?.split(':').length,
|
||||
authMethod: spawnEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (spawnEnv.CLAUDE_CONFIG_DIR ? `config-dir:${spawnEnv.CLAUDE_CONFIG_DIR}` : 'default')
|
||||
});
|
||||
|
||||
return spawnEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing generation
|
||||
*/
|
||||
cancel(projectId: string): boolean {
|
||||
const process = this.generationProcesses.get(projectId);
|
||||
if (process) {
|
||||
process.kill('SIGTERM');
|
||||
this.generationProcesses.delete(projectId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit progress update
|
||||
*/
|
||||
private emitProgress(projectId: string, progress: ChangelogGenerationProgress): void {
|
||||
this.emit('generation-progress', projectId, progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit error
|
||||
*/
|
||||
private emitError(projectId: string, error: string): void {
|
||||
this.emit('generation-progress', projectId, {
|
||||
stage: 'error',
|
||||
progress: 0,
|
||||
message: error,
|
||||
error
|
||||
});
|
||||
this.emit('generation-error', projectId, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { execSync } from 'child_process';
|
||||
import type {
|
||||
GitBranchInfo,
|
||||
GitTagInfo,
|
||||
GitCommit,
|
||||
GitHistoryOptions,
|
||||
BranchDiffOptions
|
||||
} from '../../shared/types';
|
||||
import { parseGitLogOutput } from './parser';
|
||||
|
||||
/**
|
||||
* Debug logging helper
|
||||
*/
|
||||
function debug(enabled: boolean, ...args: unknown[]): void {
|
||||
if (enabled) {
|
||||
console.log('[GitIntegration]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of branches for changelog git mode
|
||||
*/
|
||||
export function getBranches(projectPath: string, debugEnabled = false): GitBranchInfo[] {
|
||||
try {
|
||||
// Get current branch
|
||||
let currentBranch = '';
|
||||
try {
|
||||
currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
} catch {
|
||||
// Ignore - might be in detached HEAD
|
||||
}
|
||||
|
||||
// Get all branches (local and remote)
|
||||
const output = execSync('git branch -a --format="%(refname:short)|%(HEAD)"', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
const branches: GitBranchInfo[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const [name, head] = trimmed.split('|');
|
||||
if (!name) continue;
|
||||
|
||||
// Skip HEAD references
|
||||
if (name === 'HEAD' || name.includes('HEAD')) continue;
|
||||
|
||||
// Parse remote branches (origin/xxx) and mark as remote
|
||||
const isRemote = name.startsWith('origin/') || name.includes('/');
|
||||
const displayName = isRemote ? name.replace(/^origin\//, '') : name;
|
||||
|
||||
// Skip duplicates (prefer local over remote)
|
||||
if (seenNames.has(displayName) && isRemote) continue;
|
||||
seenNames.add(displayName);
|
||||
|
||||
branches.push({
|
||||
name: displayName,
|
||||
isRemote,
|
||||
isCurrent: head === '*' || displayName === currentBranch
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: current first, then local branches, then remote
|
||||
return branches.sort((a, b) => {
|
||||
if (a.isCurrent && !b.isCurrent) return -1;
|
||||
if (!a.isCurrent && b.isCurrent) return 1;
|
||||
if (!a.isRemote && b.isRemote) return -1;
|
||||
if (a.isRemote && !b.isRemote) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} catch (error) {
|
||||
debug(debugEnabled, 'Error getting branches:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of tags for changelog git mode
|
||||
*/
|
||||
export function getTags(projectPath: string, debugEnabled = false): GitTagInfo[] {
|
||||
try {
|
||||
// Get tags sorted by creation date (newest first)
|
||||
const output = execSync(
|
||||
'git tag -l --sort=-creatordate --format="%(refname:short)|%(creatordate:iso-strict)|%(objectname:short)"',
|
||||
{
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}
|
||||
);
|
||||
|
||||
const tags: GitTagInfo[] = [];
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parts = trimmed.split('|');
|
||||
const name = parts[0];
|
||||
const date = parts[1] || undefined;
|
||||
const commit = parts[2] || undefined;
|
||||
|
||||
if (name) {
|
||||
tags.push({ name, date, commit });
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
} catch (error) {
|
||||
debug(debugEnabled, 'Error getting tags:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current branch name
|
||||
*/
|
||||
export function getCurrentBranch(projectPath: string): string {
|
||||
try {
|
||||
return execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
} catch {
|
||||
return 'main';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default/main branch name
|
||||
*/
|
||||
export function getDefaultBranch(projectPath: string): string {
|
||||
try {
|
||||
// Try to get from origin/HEAD
|
||||
const result = execSync('git rev-parse --abbrev-ref origin/HEAD', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
return result.replace('origin/', '');
|
||||
} catch {
|
||||
// Fallback: check if main or master exists
|
||||
try {
|
||||
execSync('git rev-parse --verify main', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
return 'main';
|
||||
} catch {
|
||||
try {
|
||||
execSync('git rev-parse --verify master', {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
return 'master';
|
||||
} catch {
|
||||
return 'main';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commits for git-history mode
|
||||
*/
|
||||
export function getCommits(
|
||||
projectPath: string,
|
||||
options: GitHistoryOptions,
|
||||
debugEnabled = false
|
||||
): GitCommit[] {
|
||||
try {
|
||||
// Build the git log command based on options
|
||||
const format = '%h|%H|%s|%an|%ae|%aI';
|
||||
let command = `git log --pretty=format:"${format}"`;
|
||||
|
||||
// Add merge commit handling
|
||||
if (!options.includeMergeCommits) {
|
||||
command += ' --no-merges';
|
||||
}
|
||||
|
||||
// Add range/filters based on type
|
||||
switch (options.type) {
|
||||
case 'recent':
|
||||
command += ` -n ${options.count || 25}`;
|
||||
break;
|
||||
case 'since-date':
|
||||
if (options.sinceDate) {
|
||||
command += ` --since="${options.sinceDate}"`;
|
||||
}
|
||||
break;
|
||||
case 'tag-range':
|
||||
if (options.fromTag) {
|
||||
const toRef = options.toTag || 'HEAD';
|
||||
command += ` ${options.fromTag}..${toRef}`;
|
||||
}
|
||||
break;
|
||||
case 'since-version':
|
||||
// Get all commits since the specified version/tag up to HEAD
|
||||
if (options.fromTag) {
|
||||
command += ` ${options.fromTag}..HEAD`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
debug(debugEnabled, 'Getting commits with command:', command);
|
||||
|
||||
const output = execSync(command, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large histories
|
||||
});
|
||||
|
||||
return parseGitLogOutput(output);
|
||||
} catch (error) {
|
||||
debug(debugEnabled, 'Error getting commits:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commits between two branches (for branch-diff mode)
|
||||
*/
|
||||
export function getBranchDiffCommits(
|
||||
projectPath: string,
|
||||
options: BranchDiffOptions,
|
||||
debugEnabled = false
|
||||
): GitCommit[] {
|
||||
try {
|
||||
const format = '%h|%H|%s|%an|%ae|%aI';
|
||||
// Get commits in compareBranch that are not in baseBranch
|
||||
const command = `git log --pretty=format:"${format}" --no-merges ${options.baseBranch}..${options.compareBranch}`;
|
||||
|
||||
debug(debugEnabled, 'Getting branch diff commits with command:', command);
|
||||
|
||||
const output = execSync(command, {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
});
|
||||
|
||||
return parseGitLogOutput(output);
|
||||
} catch (error) {
|
||||
debug(debugEnabled, 'Error getting branch diff commits:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Changelog module - clean exports
|
||||
*
|
||||
* Architecture:
|
||||
* - changelog-service.ts: Main service facade (orchestrates all operations)
|
||||
* - generator.ts: AI-powered changelog generation
|
||||
* - parser.ts: Changelog and spec parsing logic
|
||||
* - formatter.ts: Prompt building and formatting
|
||||
* - git-integration.ts: Git operations (branches, tags, commits)
|
||||
* - types.ts: Module-specific types
|
||||
*/
|
||||
|
||||
export { ChangelogService, changelogService } from './changelog-service';
|
||||
export { ChangelogGenerator } from './generator';
|
||||
export * from './parser';
|
||||
export * from './formatter';
|
||||
export * from './git-integration';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import type { ExistingChangelog } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Extract the overview section from a spec (typically the first meaningful section)
|
||||
*/
|
||||
export function extractSpecOverview(spec: string): string {
|
||||
// Split into lines and find the Overview section
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = spec.split(/\r?\n/);
|
||||
let inOverview = false;
|
||||
let overview: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Start capturing at Overview heading
|
||||
if (/^##\s*Overview/i.test(line)) {
|
||||
inOverview = true;
|
||||
continue;
|
||||
}
|
||||
// Stop at next major heading
|
||||
if (inOverview && /^##\s/.test(line)) {
|
||||
break;
|
||||
}
|
||||
if (inOverview && line.trim()) {
|
||||
overview.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// If no overview found, take first paragraph after title
|
||||
if (overview.length === 0) {
|
||||
const paragraphs = spec.split(/\n\n+/).filter(p => !p.startsWith('#') && p.trim().length > 20);
|
||||
if (paragraphs.length > 0) {
|
||||
return paragraphs[0].substring(0, 300);
|
||||
}
|
||||
}
|
||||
|
||||
return overview.join(' ').substring(0, 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract changelog content from Claude output
|
||||
* Removes AI preambles and finds the actual changelog content
|
||||
*/
|
||||
export function extractChangelog(output: string): string {
|
||||
// Claude output should be the changelog directly
|
||||
// Clean up any potential wrapper text
|
||||
let changelog = output.trim();
|
||||
|
||||
// Find where the actual changelog starts (look for markdown heading)
|
||||
// This handles cases where AI includes preamble like "I'll analyze..." or "Here's the changelog:"
|
||||
const changelogStartPatterns = [
|
||||
/^(##\s*\[[\d.]+\])/m, // Keep-a-changelog: ## [1.0.0]
|
||||
/^(##\s*What['']?s\s+New)/im, // GitHub release: ## What's New
|
||||
/^(#\s*Release\s+v?[\d.]+)/im, // Simple: # Release v1.0.0
|
||||
/^(#\s*Changelog)/im, // # Changelog
|
||||
/^(##\s*v?[\d.]+)/m // ## v1.0.0 or ## 1.0.0
|
||||
];
|
||||
|
||||
for (const pattern of changelogStartPatterns) {
|
||||
const match = changelog.match(pattern);
|
||||
if (match && match.index !== undefined) {
|
||||
// Found a changelog heading - extract from there
|
||||
changelog = changelog.substring(match.index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Additional cleanup - remove common AI preambles if they somehow remain
|
||||
const prefixes = [
|
||||
/^I['']ll\s+analyze[^#]*(?=#)/is,
|
||||
/^I['']ll\s+generate[^#]*(?=#)/is,
|
||||
/^Here['']s the changelog[:\s]*/i,
|
||||
/^The changelog[:\s]*/i,
|
||||
/^Changelog[:\s]*/i,
|
||||
/^Based on[^#]*(?=#)/is,
|
||||
/^Let me[^#]*(?=#)/is
|
||||
];
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
changelog = changelog.replace(prefix, '');
|
||||
}
|
||||
|
||||
return changelog.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse existing changelog file and extract metadata
|
||||
*/
|
||||
export function parseExistingChangelog(filePath: string): ExistingChangelog {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Try to extract last version using common patterns
|
||||
const versionPatterns = [
|
||||
/##\s*\[(\d+\.\d+\.\d+)\]/, // Keep-a-changelog format
|
||||
/v(\d+\.\d+\.\d+)/, // v1.2.3 format
|
||||
/Version\s+(\d+\.\d+\.\d+)/i // Version 1.2.3 format
|
||||
];
|
||||
|
||||
let lastVersion: string | undefined;
|
||||
for (const pattern of versionPatterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match) {
|
||||
lastVersion = match[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
content,
|
||||
lastVersion
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
exists: true,
|
||||
error: error instanceof Error ? error.message : 'Failed to read changelog'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git log output into GitCommit objects
|
||||
*/
|
||||
export function parseGitLogOutput(output: string): Array<{
|
||||
hash: string;
|
||||
fullHash: string;
|
||||
subject: string;
|
||||
body?: string;
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
date: string;
|
||||
}> {
|
||||
const commits: Array<{
|
||||
hash: string;
|
||||
fullHash: string;
|
||||
subject: string;
|
||||
body?: string;
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
date: string;
|
||||
}> = [];
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parts = trimmed.split('|');
|
||||
if (parts.length < 6) continue;
|
||||
|
||||
const [hash, fullHash, subject, author, authorEmail, date] = parts;
|
||||
|
||||
commits.push({
|
||||
hash,
|
||||
fullHash,
|
||||
subject,
|
||||
body: undefined, // We don't fetch body for performance
|
||||
author,
|
||||
authorEmail,
|
||||
date
|
||||
});
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Changelog-specific types
|
||||
* These types extend the base types from shared/types.ts
|
||||
*/
|
||||
|
||||
export interface ChangelogConfig {
|
||||
pythonPath: string;
|
||||
claudePath: string;
|
||||
autoBuildSourcePath: string;
|
||||
}
|
||||
|
||||
export interface PromptBuildOptions {
|
||||
version: string;
|
||||
date: string;
|
||||
audience: 'technical' | 'user-facing' | 'marketing';
|
||||
format: 'keep-a-changelog' | 'simple-list' | 'github-release';
|
||||
customInstructions?: string;
|
||||
}
|
||||
|
||||
export interface VersionSuggestion {
|
||||
suggested: string;
|
||||
reason: string;
|
||||
hasBreakingChanges: boolean;
|
||||
hasNewFeatures: boolean;
|
||||
}
|
||||
|
||||
export interface GenerationScriptParams {
|
||||
prompt: string;
|
||||
claudePath: string;
|
||||
}
|
||||
@@ -776,7 +776,7 @@ export class ClaudeProfileManager {
|
||||
*/
|
||||
isProfileAuthenticated(profile: ClaudeProfile): boolean {
|
||||
const configDir = profile.configDir;
|
||||
if (!existsSync(configDir)) {
|
||||
if (!configDir || !existsSync(configDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -833,6 +833,11 @@ export class ClaudeProfileManager {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Only set CLAUDE_CONFIG_DIR if configDir is defined
|
||||
if (!profile.configDir) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
CLAUDE_CONFIG_DIR: profile.configDir
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { app, BrowserWindow, shell, nativeImage } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
|
||||
import { setupIpcHandlers } from './ipc-handlers';
|
||||
import { AgentManager } from './agent-manager';
|
||||
import { setupIpcHandlers } from './ipc-setup';
|
||||
import { AgentManager } from './agent';
|
||||
import { TerminalManager } from './terminal-manager';
|
||||
import { pythonEnvManager } from './python-env-manager';
|
||||
|
||||
|
||||
+15
-6
@@ -53,7 +53,7 @@ import type {
|
||||
} from '../shared/types';
|
||||
import { projectStore } from './project-store';
|
||||
import { fileWatcher } from './file-watcher';
|
||||
import { AgentManager } from './agent-manager';
|
||||
import { AgentManager } from './agent';
|
||||
import { TerminalManager } from './terminal-manager';
|
||||
import { getClaudeProfileManager } from './claude-profile-manager';
|
||||
import {
|
||||
@@ -119,7 +119,15 @@ export function setupIpcHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PROJECT_LIST,
|
||||
async (): Promise<IPCResult<Project[]>> => {
|
||||
// Validate that .auto-claude folders still exist for all projects
|
||||
// If a folder was deleted, reset autoBuildPath so UI prompts for reinitialization
|
||||
const resetIds = projectStore.validateProjects();
|
||||
if (resetIds.length > 0) {
|
||||
console.log('[IPC] PROJECT_LIST: Detected missing .auto-claude folders for', resetIds.length, 'project(s)');
|
||||
}
|
||||
|
||||
const projects = projectStore.getProjects();
|
||||
console.log('[IPC] PROJECT_LIST returning', projects.length, 'projects');
|
||||
return { success: true, data: projects };
|
||||
}
|
||||
);
|
||||
@@ -377,7 +385,9 @@ export function setupIpcHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_LIST,
|
||||
async (_, projectId: string): Promise<IPCResult<Task[]>> => {
|
||||
console.log('[IPC] TASK_LIST called with projectId:', projectId);
|
||||
const tasks = projectStore.getTasks(projectId);
|
||||
console.log('[IPC] TASK_LIST returning', tasks.length, 'tasks');
|
||||
return { success: true, data: tasks };
|
||||
}
|
||||
);
|
||||
@@ -2710,7 +2720,7 @@ export function setupIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
agentManager.on('exit', (taskId: string, code: number | null, processType: import('./agent-manager').ProcessType) => {
|
||||
agentManager.on('exit', (taskId: string, code: number | null, processType: import('./agent').ProcessType) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
// Stop file watcher
|
||||
@@ -2793,7 +2803,7 @@ export function setupIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
agentManager.on('execution-progress', (taskId: string, progress: import('./agent-manager').ExecutionProgressData) => {
|
||||
agentManager.on('execution-progress', (taskId: string, progress: import('./agent').ExecutionProgressData) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, taskId, progress);
|
||||
@@ -6200,9 +6210,8 @@ ${idea.rationale}
|
||||
const metadataPath = path.join(specDir, 'task_metadata.json');
|
||||
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
// Start spec creation to fill in the details - pass the existing spec directory
|
||||
// so spec_runner uses it instead of creating a new pending folder
|
||||
agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata);
|
||||
// Task is created in Planning (backlog) - user must manually start it
|
||||
// Previously auto-started spec creation here, but user should control when to start
|
||||
|
||||
// Create task object to return
|
||||
const task: Task = {
|
||||
@@ -0,0 +1,249 @@
|
||||
# IPC Handlers - Modular Architecture
|
||||
|
||||
This directory contains the refactored IPC (Inter-Process Communication) handlers for Auto Claude UI, organized into domain-specific modules for better maintainability and code organization.
|
||||
|
||||
## Overview
|
||||
|
||||
The original monolithic `ipc-handlers.ts` file (6,913 lines, 220KB) has been refactored into 16 focused modules, each handling a specific domain of the application.
|
||||
|
||||
## Module Structure
|
||||
|
||||
### Core Modules
|
||||
|
||||
#### `project-handlers.ts` (10KB)
|
||||
Handles project lifecycle and Python environment management:
|
||||
- `PROJECT_ADD` - Add new project to workspace
|
||||
- `PROJECT_REMOVE` - Remove project
|
||||
- `PROJECT_LIST` - List all projects
|
||||
- `PROJECT_UPDATE_SETTINGS` - Update project settings
|
||||
- `PROJECT_INITIALIZE` - Initialize .auto-claude directory
|
||||
- `PROJECT_CHECK_VERSION` - Check initialization status
|
||||
- `project:has-local-source` - Check if project has local auto-claude source
|
||||
- Python environment initialization and status events
|
||||
|
||||
#### `task-handlers.ts` (52KB) - Largest module
|
||||
Manages task lifecycle and execution:
|
||||
- `TASK_LIST` - List tasks for project
|
||||
- `TASK_CREATE` - Create new task with auto-generated title
|
||||
- `TASK_DELETE` - Delete task
|
||||
- `TASK_UPDATE` - Update task properties
|
||||
- `TASK_START` - Start task execution
|
||||
- `TASK_STOP` - Stop running task
|
||||
- `TASK_REVIEW` - Review task results
|
||||
- `TASK_UPDATE_STATUS` - Update task status
|
||||
- `TASK_RECOVER_STUCK` - Recover stuck tasks
|
||||
- `TASK_CHECK_RUNNING` - Check if task is running
|
||||
- `TASK_ARCHIVE` / `TASK_UNARCHIVE` - Archive management
|
||||
- Worktree operations (status, diff, merge, discard)
|
||||
- Task logs (get, watch, unwatch)
|
||||
|
||||
#### `terminal-handlers.ts` (16KB)
|
||||
Terminal and Claude profile management:
|
||||
- `TERMINAL_CREATE` - Create terminal session
|
||||
- `TERMINAL_DESTROY` - Destroy terminal
|
||||
- `TERMINAL_INPUT` - Send input to terminal
|
||||
- `TERMINAL_RESIZE` - Resize terminal
|
||||
- `TERMINAL_INVOKE_CLAUDE` - Invoke Claude in terminal
|
||||
- Claude profile management (CRUD operations)
|
||||
- Profile auto-switching and usage tracking
|
||||
- Terminal session persistence and restoration
|
||||
|
||||
#### `settings-handlers.ts` (6.3KB)
|
||||
Application settings and dialogs:
|
||||
- `SETTINGS_GET` - Get app settings
|
||||
- `SETTINGS_SAVE` - Save app settings
|
||||
- `DIALOG_SELECT_DIRECTORY` - Directory selection dialog
|
||||
- `DIALOG_CREATE_PROJECT_FOLDER` - Create project folder
|
||||
- `DIALOG_GET_DEFAULT_PROJECT_LOCATION` - Get default location
|
||||
- `APP_VERSION` - Get application version
|
||||
|
||||
#### `file-handlers.ts` (2.0KB)
|
||||
File system operations:
|
||||
- `FILE_EXPLORER_LIST` - List directory contents with filtering
|
||||
|
||||
### Feature Modules
|
||||
|
||||
#### `roadmap-handlers.ts` (12KB)
|
||||
Roadmap generation and management:
|
||||
- `ROADMAP_GET` - Get project roadmap
|
||||
- `ROADMAP_GENERATE` - Generate roadmap with AI
|
||||
- `ROADMAP_REFRESH` - Refresh roadmap
|
||||
- `ROADMAP_UPDATE_FEATURE` - Update feature status
|
||||
- `ROADMAP_CONVERT_TO_SPEC` - Convert feature to task spec
|
||||
|
||||
#### `ideation-handlers.ts` (22KB)
|
||||
AI-powered ideation system:
|
||||
- `IDEATION_GET` - Get ideation session
|
||||
- `IDEATION_GENERATE` - Generate ideas with AI
|
||||
- `IDEATION_REFRESH` - Refresh ideas
|
||||
- `IDEATION_STOP` - Stop generation
|
||||
- `IDEATION_UPDATE_IDEA` - Update idea
|
||||
- `IDEATION_CONVERT_TO_TASK` - Convert idea to task
|
||||
- `IDEATION_DISMISS` / `IDEATION_DISMISS_ALL` - Dismiss ideas
|
||||
|
||||
#### `insights-handlers.ts` (9.4KB)
|
||||
AI insights chat system:
|
||||
- `INSIGHTS_GET_SESSION` - Get chat session
|
||||
- `INSIGHTS_SEND_MESSAGE` - Send chat message
|
||||
- `INSIGHTS_CLEAR_SESSION` - Clear session
|
||||
- `INSIGHTS_CREATE_TASK` - Create task from insights
|
||||
- Session management (list, new, switch, delete, rename)
|
||||
|
||||
#### `changelog-handlers.ts` (8.2KB)
|
||||
Changelog generation:
|
||||
- `CHANGELOG_GET_DONE_TASKS` - Get completed tasks
|
||||
- `CHANGELOG_LOAD_TASK_SPECS` - Load task specifications
|
||||
- `CHANGELOG_GENERATE` - Generate changelog with AI
|
||||
- `CHANGELOG_SAVE` - Save changelog
|
||||
- `CHANGELOG_READ_EXISTING` - Read existing changelog
|
||||
- `CHANGELOG_SUGGEST_VERSION` - Suggest version number
|
||||
- Git operations (branches, tags, commits)
|
||||
|
||||
#### `context-handlers.ts` (20KB)
|
||||
Project context and memory:
|
||||
- `CONTEXT_GET` - Get project context
|
||||
- `CONTEXT_REFRESH_INDEX` - Refresh project index
|
||||
- `CONTEXT_MEMORY_STATUS` - Get Graphiti memory status
|
||||
- `CONTEXT_SEARCH_MEMORIES` - Search memory episodes
|
||||
- `CONTEXT_GET_MEMORIES` - Get memory episodes
|
||||
|
||||
### Integration Modules
|
||||
|
||||
#### `github-handlers.ts` (23KB)
|
||||
GitHub integration:
|
||||
- `GITHUB_GET_REPOSITORIES` - List repositories
|
||||
- `GITHUB_GET_ISSUES` - List issues
|
||||
- `GITHUB_GET_ISSUE` - Get single issue
|
||||
- `GITHUB_CHECK_CONNECTION` - Test connection
|
||||
- `GITHUB_INVESTIGATE_ISSUE` - AI investigation
|
||||
- `GITHUB_IMPORT_ISSUES` - Import issues as tasks
|
||||
- `GITHUB_CREATE_RELEASE` - Create GitHub release
|
||||
|
||||
#### `linear-handlers.ts` (15KB)
|
||||
Linear integration:
|
||||
- `LINEAR_GET_TEAMS` - List teams
|
||||
- `LINEAR_GET_PROJECTS` - List projects
|
||||
- `LINEAR_GET_ISSUES` - List issues
|
||||
- `LINEAR_IMPORT_ISSUES` - Import issues as tasks
|
||||
- `LINEAR_CHECK_CONNECTION` - Test connection
|
||||
|
||||
#### `env-handlers.ts` (16KB)
|
||||
Environment configuration:
|
||||
- `ENV_GET` - Get project environment
|
||||
- `ENV_UPDATE` - Update environment variables
|
||||
- `ENV_CHECK_CLAUDE_AUTH` - Check Claude authentication
|
||||
- `ENV_INVOKE_CLAUDE_SETUP` - Run Claude setup
|
||||
|
||||
#### `autobuild-source-handlers.ts` (8.9KB)
|
||||
Auto-build source updates:
|
||||
- `AUTOBUILD_SOURCE_CHECK` - Check for updates
|
||||
- `AUTOBUILD_SOURCE_DOWNLOAD` - Download updates
|
||||
- `AUTOBUILD_SOURCE_VERSION` - Get version info
|
||||
- Source environment configuration
|
||||
|
||||
### Event Handlers
|
||||
|
||||
#### `agent-events-handlers.ts` (6.1KB)
|
||||
Agent event forwarding to renderer:
|
||||
- Agent log events
|
||||
- Agent error events
|
||||
- SDK rate limit events
|
||||
- Agent exit events with status transitions
|
||||
- Execution progress events
|
||||
- File watcher events
|
||||
- Implementation plan updates
|
||||
|
||||
## Entry Point
|
||||
|
||||
### `index.ts` (3.6KB)
|
||||
Central registration point that:
|
||||
- Imports all handler modules
|
||||
- Exports `setupIpcHandlers()` function
|
||||
- Configures services with dependencies
|
||||
- Registers all handlers in organized sequence
|
||||
- Re-exports individual registration functions
|
||||
|
||||
### Main IPC Handlers File
|
||||
The refactored `ipc-handlers.ts` now:
|
||||
- Imports `setupIpcHandlers` from `./ipc-handlers`
|
||||
- Delegates all registration to modular handlers
|
||||
- Provides clear documentation of module organization
|
||||
- Reduced from 6,913 lines to ~50 lines
|
||||
|
||||
## Benefits of Modular Architecture
|
||||
|
||||
### Maintainability
|
||||
- **Focused Modules**: Each module has a single responsibility
|
||||
- **Clear Boundaries**: Domain separation makes code easier to understand
|
||||
- **Reduced Complexity**: Smaller files are easier to navigate and modify
|
||||
- **Better Organization**: Related handlers grouped together
|
||||
|
||||
### Testability
|
||||
- **Isolated Testing**: Each module can be tested independently
|
||||
- **Mock Dependencies**: Easier to mock services for unit tests
|
||||
- **Focused Test Suites**: Test files can mirror module structure
|
||||
|
||||
### Developer Experience
|
||||
- **Easier Navigation**: Find handlers by domain, not by scrolling
|
||||
- **Reduced Conflicts**: Multiple developers can work on different modules
|
||||
- **Clear Imports**: Each module declares its dependencies explicitly
|
||||
- **Better IDE Support**: Faster intellisense and type checking
|
||||
|
||||
### Code Quality
|
||||
- **Explicit Dependencies**: Each module imports only what it needs
|
||||
- **Type Safety**: Proper TypeScript imports throughout
|
||||
- **Consistent Patterns**: Uniform registration pattern across modules
|
||||
- **Documentation**: Each module has clear purpose and scope
|
||||
|
||||
## Usage
|
||||
|
||||
To register all IPC handlers:
|
||||
|
||||
```typescript
|
||||
import { setupIpcHandlers } from './ipc-handlers';
|
||||
import { AgentManager } from './agent-manager';
|
||||
import { TerminalManager } from './terminal-manager';
|
||||
import { PythonEnvManager } from './python-env-manager';
|
||||
|
||||
// Initialize services
|
||||
const agentManager = new AgentManager();
|
||||
const terminalManager = new TerminalManager();
|
||||
const pythonEnvManager = new PythonEnvManager();
|
||||
|
||||
// Register all handlers
|
||||
setupIpcHandlers(
|
||||
agentManager,
|
||||
terminalManager,
|
||||
() => mainWindow,
|
||||
pythonEnvManager
|
||||
);
|
||||
```
|
||||
|
||||
To register individual modules:
|
||||
|
||||
```typescript
|
||||
import { registerTaskHandlers } from './ipc-handlers/task-handlers';
|
||||
|
||||
registerTaskHandlers(agentManager, () => mainWindow);
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
The refactoring maintains 100% backward compatibility:
|
||||
- All IPC channel names unchanged
|
||||
- All handler signatures unchanged
|
||||
- All service dependencies preserved
|
||||
- Event forwarding logic unchanged
|
||||
|
||||
Original file backed up as `ipc-handlers.ts.backup`.
|
||||
|
||||
## Module Dependencies
|
||||
|
||||
Each module may depend on:
|
||||
- **Services**: AgentManager, TerminalManager, ChangelogService, etc.
|
||||
- **Stores**: projectStore
|
||||
- **Utilities**: fileWatcher, titleGenerator
|
||||
- **Constants**: IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir
|
||||
- **Types**: Extensive TypeScript types from shared/types
|
||||
|
||||
All dependencies are explicitly imported at the module level.
|
||||
@@ -0,0 +1,179 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
|
||||
import type {
|
||||
IPCResult,
|
||||
SDKRateLimitInfo,
|
||||
Task,
|
||||
TaskStatus,
|
||||
Project,
|
||||
ImplementationPlan,
|
||||
ExecutionProgress
|
||||
} 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';
|
||||
|
||||
|
||||
/**
|
||||
* Register all agent-events-related IPC handlers
|
||||
*/
|
||||
export function registerAgenteventsHandlers(
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Agent Manager Events → Renderer
|
||||
// ============================================
|
||||
|
||||
agentManager.on('log', (taskId: string, log: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_LOG, taskId, log);
|
||||
}
|
||||
});
|
||||
|
||||
agentManager.on('error', (taskId: string, error: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
agentManager.on('exit', (taskId: string, code: number | null, processType: ProcessType) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
// Stop file watcher
|
||||
fileWatcher.unwatch(taskId);
|
||||
|
||||
// Determine new status based on process type and exit code
|
||||
// Flow: Planning → In Progress → AI Review (QA agent) → Human Review (QA passed)
|
||||
let newStatus: TaskStatus;
|
||||
|
||||
if (processType === 'task-execution') {
|
||||
// Task execution completed (includes spec_runner → run.py chain)
|
||||
// Success (code 0) = QA agent signed off → Human Review
|
||||
// Failure = needs human attention → Human Review
|
||||
newStatus = 'human_review';
|
||||
} else if (processType === 'qa-process') {
|
||||
// QA retry process completed
|
||||
newStatus = 'human_review';
|
||||
} else if (processType === 'spec-creation') {
|
||||
// Pure spec creation (shouldn't happen with current flow, but handle it)
|
||||
// Stay in backlog/planning
|
||||
console.log(`[Task ${taskId}] Spec creation completed with code ${code}`);
|
||||
return;
|
||||
} else {
|
||||
// Unknown process type
|
||||
newStatus = 'human_review';
|
||||
}
|
||||
|
||||
// Persist status to disk so it survives hot reload
|
||||
// This is a backup in case the Python backend didn't sync properly
|
||||
try {
|
||||
const projects = projectStore.getProjects();
|
||||
let task: Task | undefined;
|
||||
let project: Project | undefined;
|
||||
|
||||
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 specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specDir = path.join(project.path, specsBaseDir, task.specId);
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
|
||||
if (existsSync(planPath)) {
|
||||
const planContent = readFileSync(planPath, 'utf-8');
|
||||
const plan = JSON.parse(planContent);
|
||||
|
||||
// Only update if not already set to a "further along" status
|
||||
// (e.g., don't override 'done' with 'human_review')
|
||||
const currentStatus = plan.status;
|
||||
const shouldUpdate = !currentStatus ||
|
||||
currentStatus === 'in_progress' ||
|
||||
currentStatus === 'ai_review' ||
|
||||
currentStatus === 'backlog' ||
|
||||
currentStatus === 'pending';
|
||||
|
||||
if (shouldUpdate) {
|
||||
plan.status = newStatus;
|
||||
plan.planStatus = 'review';
|
||||
plan.updated_at = new Date().toISOString();
|
||||
writeFileSync(planPath, JSON.stringify(plan, null, 2));
|
||||
console.log(`[Task ${taskId}] Persisted status '${newStatus}' to implementation_plan.json`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (persistError) {
|
||||
console.error(`[Task ${taskId}] Failed to persist status:`, persistError);
|
||||
}
|
||||
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.TASK_STATUS_CHANGE,
|
||||
taskId,
|
||||
newStatus
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
agentManager.on('execution-progress', (taskId: string, progress: ExecutionProgressData) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, taskId, progress);
|
||||
|
||||
// Auto-move task to AI Review when entering qa_review phase
|
||||
if (progress.phase === 'qa_review') {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.TASK_STATUS_CHANGE,
|
||||
taskId,
|
||||
'ai_review'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// File Watcher Events → Renderer
|
||||
// ============================================
|
||||
|
||||
fileWatcher.on('progress', (taskId: string, plan: ImplementationPlan) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, plan);
|
||||
}
|
||||
});
|
||||
|
||||
fileWatcher.on('error', (taskId: string, error: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type { IPCResult } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import type { AutoBuildSourceUpdateProgress, SourceEnvConfig, SourceEnvCheckResult } from '../../shared/types';
|
||||
import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveSourcePath } from '../auto-claude-updater';
|
||||
|
||||
|
||||
/**
|
||||
* Register all autobuild-source-related IPC handlers
|
||||
*/
|
||||
export function registerAutobuildSourceHandlers(
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Auto Claude Source Update Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK,
|
||||
async (): Promise<IPCResult<{ updateAvailable: boolean; currentVersion: string; latestVersion?: string; releaseNotes?: string; error?: string }>> => {
|
||||
try {
|
||||
const result = await checkSourceUpdates();
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD,
|
||||
() => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
// Start download in background
|
||||
downloadAndApplyUpdate((progress) => {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
progress
|
||||
);
|
||||
}).then((result) => {
|
||||
if (result.success) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'complete',
|
||||
message: `Updated to version ${result.version}`
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
} else {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'error',
|
||||
message: result.error || 'Update failed'
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
}
|
||||
}).catch((error) => {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'error',
|
||||
message: error instanceof Error ? error.message : 'Update failed'
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
});
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'checking',
|
||||
message: 'Starting update...'
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION,
|
||||
async (): Promise<IPCResult<string>> => {
|
||||
try {
|
||||
const version = getBundledVersion();
|
||||
return { success: true, data: version };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get version'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Auto Claude Source Environment Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Parse an .env file content into a key-value object
|
||||
*/
|
||||
const parseSourceEnvFile = (content: string): Record<string, string> => {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
// Remove quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
vars[key] = value;
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
};
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET,
|
||||
async (): Promise<IPCResult<SourceEnvConfig>> => {
|
||||
try {
|
||||
const sourcePath = getEffectiveSourcePath();
|
||||
if (!sourcePath) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasClaudeToken: false,
|
||||
envExists: false,
|
||||
sourcePath: undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const envPath = path.join(sourcePath, '.env');
|
||||
const envExists = existsSync(envPath);
|
||||
|
||||
if (!envExists) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasClaudeToken: false,
|
||||
envExists: false,
|
||||
sourcePath
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const vars = parseSourceEnvFile(content);
|
||||
const hasToken = !!vars['CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasClaudeToken: hasToken,
|
||||
claudeOAuthToken: hasToken ? vars['CLAUDE_CODE_OAUTH_TOKEN'] : undefined,
|
||||
envExists: true,
|
||||
sourcePath
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get source env'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE,
|
||||
async (_, config: { claudeOAuthToken?: string }): Promise<IPCResult> => {
|
||||
try {
|
||||
const sourcePath = getEffectiveSourcePath();
|
||||
if (!sourcePath) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Auto-Claude source path not found. Please configure it in App Settings.'
|
||||
};
|
||||
}
|
||||
|
||||
const envPath = path.join(sourcePath, '.env');
|
||||
|
||||
// Read existing content or start fresh
|
||||
let existingContent = '';
|
||||
const existingVars: Record<string, string> = {};
|
||||
|
||||
if (existsSync(envPath)) {
|
||||
existingContent = readFileSync(envPath, 'utf-8');
|
||||
Object.assign(existingVars, parseSourceEnvFile(existingContent));
|
||||
}
|
||||
|
||||
// Update the token
|
||||
if (config.claudeOAuthToken !== undefined) {
|
||||
existingVars['CLAUDE_CODE_OAUTH_TOKEN'] = config.claudeOAuthToken;
|
||||
}
|
||||
|
||||
// Rebuild the .env file preserving comments and structure
|
||||
const lines = existingContent.split('\n');
|
||||
const processedKeys = new Set<string>();
|
||||
const outputLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
outputLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
if (key in existingVars) {
|
||||
outputLines.push(`${key}=${existingVars[key]}`);
|
||||
processedKeys.add(key);
|
||||
} else {
|
||||
outputLines.push(line);
|
||||
}
|
||||
} else {
|
||||
outputLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new keys that weren't in the original file
|
||||
for (const [key, value] of Object.entries(existingVars)) {
|
||||
if (!processedKeys.has(key)) {
|
||||
outputLines.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(envPath, outputLines.join('\n'));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update source env'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN,
|
||||
async (): Promise<IPCResult<SourceEnvCheckResult>> => {
|
||||
try {
|
||||
const sourcePath = getEffectiveSourcePath();
|
||||
if (!sourcePath) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasToken: false,
|
||||
sourcePath: undefined,
|
||||
error: 'Auto-Claude source path not found'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const envPath = path.join(sourcePath, '.env');
|
||||
if (!existsSync(envPath)) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasToken: false,
|
||||
sourcePath,
|
||||
error: '.env file does not exist'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const vars = parseSourceEnvFile(content);
|
||||
const hasToken = !!vars['CLAUDE_CODE_OAUTH_TOKEN'] && vars['CLAUDE_CODE_OAUTH_TOKEN'].length > 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasToken,
|
||||
sourcePath
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check source token'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { IPC_CHANNELS, getSpecsDir } from '../../shared/constants';
|
||||
import type {
|
||||
IPCResult,
|
||||
Task,
|
||||
ChangelogTask,
|
||||
TaskSpecContent,
|
||||
ChangelogGenerationRequest,
|
||||
ChangelogSaveRequest,
|
||||
ChangelogSaveResult,
|
||||
ExistingChangelog,
|
||||
GitBranchInfo,
|
||||
GitTagInfo,
|
||||
GitCommit,
|
||||
GitHistoryOptions,
|
||||
BranchDiffOptions
|
||||
} from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
import { changelogService } from '../changelog-service';
|
||||
|
||||
/**
|
||||
* Register all changelog-related IPC handlers
|
||||
*/
|
||||
export function registerChangelogHandlers(
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Changelog Event Handlers
|
||||
// ============================================
|
||||
|
||||
changelogService.on('generation-progress', (projectId: string, progress: import('../../shared/types').ChangelogGenerationProgress) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CHANGELOG_GENERATION_PROGRESS, projectId, progress);
|
||||
}
|
||||
});
|
||||
|
||||
changelogService.on('generation-complete', (projectId: string, result: import('../../shared/types').ChangelogGenerationResult) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CHANGELOG_GENERATION_COMPLETE, projectId, result);
|
||||
}
|
||||
});
|
||||
|
||||
changelogService.on('generation-error', (projectId: string, error: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, projectId, error);
|
||||
}
|
||||
});
|
||||
|
||||
changelogService.on('rate-limit', (projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Changelog Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS,
|
||||
async (_, projectId: string, rendererTasks?: Task[]): Promise<IPCResult<ChangelogTask[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// Use renderer tasks if provided (they have the correct UI status),
|
||||
// otherwise fall back to reading from filesystem
|
||||
const tasks = rendererTasks || projectStore.getTasks(projectId);
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const doneTasks = changelogService.getCompletedTasks(project.path, tasks, specsBaseDir);
|
||||
|
||||
return { success: true, data: doneTasks };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_LOAD_TASK_SPECS,
|
||||
async (_, projectId: string, taskIds: string[]): Promise<IPCResult<TaskSpecContent[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const tasks = projectStore.getTasks(projectId);
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir);
|
||||
|
||||
return { success: true, data: specs };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.CHANGELOG_GENERATE,
|
||||
async (_, request: ChangelogGenerationRequest) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(request.projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.CHANGELOG_GENERATION_ERROR,
|
||||
request.projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load specs for selected tasks (only in tasks mode)
|
||||
let specs: TaskSpecContent[] = [];
|
||||
if (request.sourceMode === 'tasks' && request.taskIds && request.taskIds.length > 0) {
|
||||
const tasks = projectStore.getTasks(request.projectId);
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
specs = await changelogService.loadTaskSpecs(project.path, request.taskIds, tasks, specsBaseDir);
|
||||
}
|
||||
|
||||
// Start generation
|
||||
changelogService.generateChangelog(request.projectId, project.path, request, specs);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_SAVE,
|
||||
async (_, request: ChangelogSaveRequest): Promise<IPCResult<ChangelogSaveResult>> => {
|
||||
const project = projectStore.getProject(request.projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = changelogService.saveChangelog(project.path, request);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save changelog'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_READ_EXISTING,
|
||||
async (_, projectId: string): Promise<IPCResult<ExistingChangelog>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const result = changelogService.readExistingChangelog(project.path);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION,
|
||||
async (_, projectId: string, taskIds: string[]): Promise<IPCResult<{ version: string; reason: string }>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current version from existing changelog
|
||||
const existing = changelogService.readExistingChangelog(project.path);
|
||||
const currentVersion = existing.lastVersion;
|
||||
|
||||
// Load specs for selected tasks to analyze change types
|
||||
const tasks = projectStore.getTasks(projectId);
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir);
|
||||
|
||||
// Analyze specs and suggest version
|
||||
const suggestedVersion = changelogService.suggestVersion(specs, currentVersion);
|
||||
|
||||
// Determine reason for the suggestion
|
||||
let reason = 'patch';
|
||||
if (currentVersion) {
|
||||
const [oldMajor, oldMinor] = currentVersion.split('.').map(Number);
|
||||
const [newMajor, newMinor] = suggestedVersion.split('.').map(Number);
|
||||
if (newMajor > oldMajor) {
|
||||
reason = 'breaking';
|
||||
} else if (newMinor > oldMinor) {
|
||||
reason = 'feature';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { version: suggestedVersion, reason }
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to suggest version'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Changelog Git Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_GET_BRANCHES,
|
||||
async (_, projectId: string): Promise<IPCResult<GitBranchInfo[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const branches = changelogService.getBranches(project.path);
|
||||
return { success: true, data: branches };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get branches'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_GET_TAGS,
|
||||
async (_, projectId: string): Promise<IPCResult<GitTagInfo[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const tags = changelogService.getTags(project.path);
|
||||
return { success: true, data: tags };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get tags'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_GET_COMMITS_PREVIEW,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
options: GitHistoryOptions | BranchDiffOptions,
|
||||
mode: 'git-history' | 'branch-diff'
|
||||
): Promise<IPCResult<GitCommit[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
let commits: GitCommit[];
|
||||
|
||||
if (mode === 'git-history') {
|
||||
commits = changelogService.getCommits(
|
||||
project.path,
|
||||
options as GitHistoryOptions
|
||||
);
|
||||
} else {
|
||||
commits = changelogService.getBranchDiffCommits(
|
||||
project.path,
|
||||
options as BranchDiffOptions
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true, data: commits };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get commits preview'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Changelog Agent Events → Renderer
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { IPC_CHANNELS, getSpecsDir } from '../../shared/constants';
|
||||
import type {
|
||||
IPCResult,
|
||||
Task,
|
||||
ChangelogTask,
|
||||
TaskSpecContent,
|
||||
ChangelogGenerationRequest,
|
||||
ChangelogSaveRequest,
|
||||
ChangelogSaveResult,
|
||||
ExistingChangelog,
|
||||
GitBranchInfo,
|
||||
GitTagInfo,
|
||||
GitCommit,
|
||||
GitHistoryOptions,
|
||||
BranchDiffOptions
|
||||
} from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
import { changelogService } from '../changelog-service';
|
||||
|
||||
/**
|
||||
* Register all changelog-related IPC handlers
|
||||
*/
|
||||
export function registerChangelogHandlers(
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// Changelog Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS,
|
||||
async (_, projectId: string, rendererTasks?: import('../shared/types').Task[]): Promise<IPCResult<import('../shared/types').ChangelogTask[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// Use renderer tasks if provided (they have the correct UI status),
|
||||
// otherwise fall back to reading from filesystem
|
||||
const tasks = rendererTasks || projectStore.getTasks(projectId);
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const doneTasks = changelogService.getCompletedTasks(project.path, tasks, specsBaseDir);
|
||||
|
||||
return { success: true, data: doneTasks };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_LOAD_TASK_SPECS,
|
||||
async (_, projectId: string, taskIds: string[]): Promise<IPCResult<import('../shared/types').TaskSpecContent[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const tasks = projectStore.getTasks(projectId);
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir);
|
||||
|
||||
return { success: true, data: specs };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.CHANGELOG_GENERATE,
|
||||
async (_, request: import('../shared/types').ChangelogGenerationRequest) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(request.projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.CHANGELOG_GENERATION_ERROR,
|
||||
request.projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load specs for selected tasks (only in tasks mode)
|
||||
let specs: import('../shared/types').TaskSpecContent[] = [];
|
||||
if (request.sourceMode === 'tasks' && request.taskIds && request.taskIds.length > 0) {
|
||||
const tasks = projectStore.getTasks(request.projectId);
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
specs = await changelogService.loadTaskSpecs(project.path, request.taskIds, tasks, specsBaseDir);
|
||||
}
|
||||
|
||||
// Start generation
|
||||
changelogService.generateChangelog(request.projectId, project.path, request, specs);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_SAVE,
|
||||
async (_, request: import('../shared/types').ChangelogSaveRequest): Promise<IPCResult<import('../shared/types').ChangelogSaveResult>> => {
|
||||
const project = projectStore.getProject(request.projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = changelogService.saveChangelog(project.path, request);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save changelog'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_READ_EXISTING,
|
||||
async (_, projectId: string): Promise<IPCResult<import('../shared/types').ExistingChangelog>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const result = changelogService.readExistingChangelog(project.path);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION,
|
||||
async (_, projectId: string, taskIds: string[]): Promise<IPCResult<{ version: string; reason: string }>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current version from existing changelog
|
||||
const existing = changelogService.readExistingChangelog(project.path);
|
||||
const currentVersion = existing.lastVersion;
|
||||
|
||||
// Load specs for selected tasks to analyze change types
|
||||
const tasks = projectStore.getTasks(projectId);
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir);
|
||||
|
||||
// Analyze specs and suggest version
|
||||
const suggestedVersion = changelogService.suggestVersion(specs, currentVersion);
|
||||
|
||||
// Determine reason for the suggestion
|
||||
let reason = 'patch';
|
||||
if (currentVersion) {
|
||||
const [oldMajor, oldMinor] = currentVersion.split('.').map(Number);
|
||||
const [newMajor, newMinor] = suggestedVersion.split('.').map(Number);
|
||||
if (newMajor > oldMajor) {
|
||||
reason = 'breaking';
|
||||
} else if (newMinor > oldMinor) {
|
||||
reason = 'feature';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { version: suggestedVersion, reason }
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to suggest version'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Changelog Git Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_GET_BRANCHES,
|
||||
async (_, projectId: string): Promise<IPCResult<import('../shared/types').GitBranchInfo[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const branches = changelogService.getBranches(project.path);
|
||||
return { success: true, data: branches };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get branches'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_GET_TAGS,
|
||||
async (_, projectId: string): Promise<IPCResult<import('../shared/types').GitTagInfo[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const tags = changelogService.getTags(project.path);
|
||||
return { success: true, data: tags };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get tags'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CHANGELOG_GET_COMMITS_PREVIEW,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
options: import('../shared/types').GitHistoryOptions | import('../shared/types').BranchDiffOptions,
|
||||
mode: 'git-history' | 'branch-diff'
|
||||
): Promise<IPCResult<import('../shared/types').GitCommit[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
let commits: import('../shared/types').GitCommit[];
|
||||
|
||||
if (mode === 'git-history') {
|
||||
commits = changelogService.getCommits(
|
||||
project.path,
|
||||
options as import('../shared/types').GitHistoryOptions
|
||||
);
|
||||
} else {
|
||||
commits = changelogService.getBranchDiffCommits(
|
||||
project.path,
|
||||
options as import('../shared/types').BranchDiffOptions
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true, data: commits };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get commits preview'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Changelog Agent Events → Renderer
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import { ipcMain, app } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
|
||||
import type {
|
||||
IPCResult,
|
||||
ProjectContextData,
|
||||
GraphitiMemoryStatus,
|
||||
MemoryEpisode,
|
||||
ContextSearchResult,
|
||||
ProjectIndex,
|
||||
GraphitiMemoryState
|
||||
} from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
const getAutoBuildSourcePath = (): string | null => {
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
if (settings.autoBuildPath && existsSync(settings.autoBuildPath)) {
|
||||
return settings.autoBuildPath;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to null
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Register all context-related IPC handlers
|
||||
*/
|
||||
export function registerContextHandlers(
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Context Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_GET,
|
||||
async (_, projectId: string): Promise<IPCResult<ProjectContextData>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Load project index
|
||||
let projectIndex: ProjectIndex | null = null;
|
||||
const indexPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX);
|
||||
if (existsSync(indexPath)) {
|
||||
const content = readFileSync(indexPath, 'utf-8');
|
||||
projectIndex = JSON.parse(content);
|
||||
}
|
||||
|
||||
// Load graphiti state from most recent spec or project root
|
||||
let memoryState: GraphitiMemoryState | null = null;
|
||||
let memoryStatus: GraphitiMemoryStatus = {
|
||||
enabled: false,
|
||||
available: false,
|
||||
reason: 'Graphiti not configured'
|
||||
};
|
||||
|
||||
// Check for graphiti state in specs
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
if (existsSync(specsDir)) {
|
||||
const specDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
})
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const specDir of specDirs) {
|
||||
const statePath = path.join(specsDir, specDir, AUTO_BUILD_PATHS.GRAPHITI_STATE);
|
||||
if (existsSync(statePath)) {
|
||||
const stateContent = readFileSync(statePath, 'utf-8');
|
||||
memoryState = JSON.parse(stateContent);
|
||||
|
||||
// If we found a state, update memory status
|
||||
if (memoryState?.initialized) {
|
||||
memoryStatus = {
|
||||
enabled: true,
|
||||
available: true,
|
||||
database: memoryState.database || 'auto_build_memory',
|
||||
host: process.env.GRAPHITI_FALKORDB_HOST || 'localhost',
|
||||
port: parseInt(process.env.GRAPHITI_FALKORDB_PORT || '6380', 10)
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check environment for Graphiti config if not found in specs
|
||||
if (!memoryState) {
|
||||
// Load project .env file and global settings to check for Graphiti config
|
||||
let projectEnvVars: Record<string, string> = {};
|
||||
if (project.autoBuildPath) {
|
||||
const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
if (existsSync(projectEnvPath)) {
|
||||
try {
|
||||
const envContent = readFileSync(projectEnvPath, 'utf-8');
|
||||
// Parse .env file inline - handle both Unix and Windows line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
projectEnvVars[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue with empty vars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load global settings for OpenAI API key fallback
|
||||
let globalOpenAIKey: string | undefined;
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const settingsContent = readFileSync(settingsPath, 'utf-8');
|
||||
const globalSettings = JSON.parse(settingsContent);
|
||||
globalOpenAIKey = globalSettings.globalOpenAIApiKey;
|
||||
} catch {
|
||||
// Continue without global settings
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Graphiti config: project .env > process.env
|
||||
const graphitiEnabled =
|
||||
projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' ||
|
||||
process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true';
|
||||
|
||||
// Check for OpenAI key: project .env > global settings > process.env
|
||||
const hasOpenAI =
|
||||
!!projectEnvVars['OPENAI_API_KEY'] ||
|
||||
!!globalOpenAIKey ||
|
||||
!!process.env.OPENAI_API_KEY;
|
||||
|
||||
// Get Graphiti connection details from project .env or process.env
|
||||
const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost';
|
||||
const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10);
|
||||
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory';
|
||||
|
||||
if (graphitiEnabled && hasOpenAI) {
|
||||
memoryStatus = {
|
||||
enabled: true,
|
||||
available: true,
|
||||
host: graphitiHost,
|
||||
port: graphitiPort,
|
||||
database: graphitiDatabase
|
||||
};
|
||||
} else if (graphitiEnabled && !hasOpenAI) {
|
||||
memoryStatus = {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reason: 'OPENAI_API_KEY not set (required for Graphiti embeddings)'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent memories from file-based memory (session insights)
|
||||
const recentMemories: MemoryEpisode[] = [];
|
||||
if (existsSync(specsDir)) {
|
||||
const recentSpecDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
})
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 10); // Last 10 specs
|
||||
|
||||
for (const specDir of recentSpecDirs) {
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
// Load session insights from session_insights subdirectory
|
||||
const sessionInsightsDir = path.join(memoryDir, 'session_insights');
|
||||
if (existsSync(sessionInsightsDir)) {
|
||||
const sessionFiles = readdirSync(sessionInsightsDir)
|
||||
.filter((f: string) => f.startsWith('session_') && f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const sessionFile of sessionFiles.slice(0, 3)) {
|
||||
try {
|
||||
const sessionPath = path.join(sessionInsightsDir, sessionFile);
|
||||
const sessionContent = readFileSync(sessionPath, 'utf-8');
|
||||
const sessionData = JSON.parse(sessionContent);
|
||||
|
||||
// Session files have: session_number, timestamp, subtasks_completed,
|
||||
// discoveries, what_worked, what_failed, recommendations_for_next_session
|
||||
if (sessionData.session_number !== undefined) {
|
||||
recentMemories.push({
|
||||
id: `${specDir}-${sessionFile}`,
|
||||
type: 'session_insight',
|
||||
timestamp: sessionData.timestamp || new Date().toISOString(),
|
||||
content: JSON.stringify({
|
||||
discoveries: sessionData.discoveries,
|
||||
what_worked: sessionData.what_worked,
|
||||
what_failed: sessionData.what_failed,
|
||||
recommendations: sessionData.recommendations_for_next_session,
|
||||
subtasks_completed: sessionData.subtasks_completed
|
||||
}, null, 2),
|
||||
session_number: sessionData.session_number
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also load codebase_map.json as a memory item
|
||||
const codebaseMapPath = path.join(memoryDir, 'codebase_map.json');
|
||||
if (existsSync(codebaseMapPath)) {
|
||||
try {
|
||||
const mapContent = readFileSync(codebaseMapPath, 'utf-8');
|
||||
const mapData = JSON.parse(mapContent);
|
||||
if (mapData.discovered_files && Object.keys(mapData.discovered_files).length > 0) {
|
||||
recentMemories.push({
|
||||
id: `${specDir}-codebase_map`,
|
||||
type: 'codebase_map',
|
||||
timestamp: mapData.last_updated || new Date().toISOString(),
|
||||
content: JSON.stringify(mapData.discovered_files, null, 2),
|
||||
session_number: undefined
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
projectIndex,
|
||||
memoryStatus,
|
||||
memoryState,
|
||||
recentMemories: recentMemories.slice(0, 20),
|
||||
isLoading: false
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load project context'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_REFRESH_INDEX,
|
||||
async (_, projectId: string): Promise<IPCResult<ProjectIndex>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Run the analyzer script to regenerate project_index.json
|
||||
const autoBuildSource = getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Auto-build source path not configured'
|
||||
};
|
||||
}
|
||||
|
||||
const analyzerPath = path.join(autoBuildSource, 'analyzer.py');
|
||||
const indexOutputPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX);
|
||||
|
||||
// Run analyzer
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn('python', [
|
||||
analyzerPath,
|
||||
'--project-dir', project.path,
|
||||
'--output', indexOutputPath
|
||||
], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env }
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Analyzer exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
|
||||
// Read the new index
|
||||
if (existsSync(indexOutputPath)) {
|
||||
const content = readFileSync(indexOutputPath, 'utf-8');
|
||||
const projectIndex = JSON.parse(content);
|
||||
return { success: true, data: projectIndex };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to generate project index' };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to refresh project index'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_MEMORY_STATUS,
|
||||
async (_, projectId: string): Promise<IPCResult<GraphitiMemoryStatus>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// Load project .env file to check for Graphiti config
|
||||
let projectEnvVars: Record<string, string> = {};
|
||||
if (project.autoBuildPath) {
|
||||
const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
if (existsSync(projectEnvPath)) {
|
||||
try {
|
||||
const envContent = readFileSync(projectEnvPath, 'utf-8');
|
||||
// Parse .env file inline - handle both Unix and Windows line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
projectEnvVars[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue with empty vars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load global settings for OpenAI API key fallback
|
||||
let globalOpenAIKey: string | undefined;
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const settingsContent = readFileSync(settingsPath, 'utf-8');
|
||||
const globalSettings = JSON.parse(settingsContent);
|
||||
globalOpenAIKey = globalSettings.globalOpenAIApiKey;
|
||||
} catch {
|
||||
// Continue without global settings
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Graphiti config: project .env > process.env
|
||||
const graphitiEnabled =
|
||||
projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' ||
|
||||
process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true';
|
||||
|
||||
// Check for OpenAI key: project .env > global settings > process.env
|
||||
const hasOpenAI =
|
||||
!!projectEnvVars['OPENAI_API_KEY'] ||
|
||||
!!globalOpenAIKey ||
|
||||
!!process.env.OPENAI_API_KEY;
|
||||
|
||||
// Get Graphiti connection details from project .env or process.env
|
||||
const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost';
|
||||
const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10);
|
||||
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory';
|
||||
|
||||
if (!graphitiEnabled) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: false,
|
||||
available: false,
|
||||
reason: 'GRAPHITI_ENABLED not set to true'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasOpenAI) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reason: 'OPENAI_API_KEY not set (required for embeddings)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
available: true,
|
||||
host: graphitiHost,
|
||||
port: graphitiPort,
|
||||
database: graphitiDatabase
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_SEARCH_MEMORIES,
|
||||
async (_, projectId: string, query: string): Promise<IPCResult<ContextSearchResult[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// For now, do simple text search in file-based memories
|
||||
// Graphiti search would require running Python subprocess
|
||||
const results: ContextSearchResult[] = [];
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
if (existsSync(specsDir)) {
|
||||
const allSpecDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
});
|
||||
|
||||
for (const specDir of allSpecDirs) {
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
const memoryFiles = readdirSync(memoryDir)
|
||||
.filter((f: string) => f.endsWith('.json'));
|
||||
|
||||
for (const memFile of memoryFiles) {
|
||||
try {
|
||||
const memPath = path.join(memoryDir, memFile);
|
||||
const memContent = readFileSync(memPath, 'utf-8');
|
||||
|
||||
if (memContent.toLowerCase().includes(queryLower)) {
|
||||
const memData = JSON.parse(memContent);
|
||||
results.push({
|
||||
content: JSON.stringify(memData.insights || memData, null, 2),
|
||||
score: 1.0,
|
||||
type: 'session_insight'
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: results.slice(0, 20) };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_GET_MEMORIES,
|
||||
async (_, projectId: string, limit: number = 20): Promise<IPCResult<MemoryEpisode[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const memories: MemoryEpisode[] = [];
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
|
||||
if (existsSync(specsDir)) {
|
||||
const sortedSpecDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
})
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const specDir of sortedSpecDirs) {
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
const memoryFiles = readdirSync(memoryDir)
|
||||
.filter((f: string) => f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const memFile of memoryFiles) {
|
||||
try {
|
||||
const memPath = path.join(memoryDir, memFile);
|
||||
const memContent = readFileSync(memPath, 'utf-8');
|
||||
const memData = JSON.parse(memContent);
|
||||
|
||||
memories.push({
|
||||
id: `${specDir}-${memFile}`,
|
||||
type: memData.type || 'session_insight',
|
||||
timestamp: memData.timestamp || new Date().toISOString(),
|
||||
content: JSON.stringify(memData.insights || memData, null, 2),
|
||||
session_number: memData.session_number
|
||||
});
|
||||
|
||||
if (memories.length >= limit) {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (memories.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: memories };
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS } from '../../shared/constants';
|
||||
import type { IPCResult, ProjectEnvConfig, ClaudeAuthResult, AppSettings } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { projectStore } from '../project-store';
|
||||
import { parseEnvFile } from './utils';
|
||||
|
||||
|
||||
/**
|
||||
* Register all env-related IPC handlers
|
||||
*/
|
||||
export function registerEnvHandlers(
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Environment Configuration Operations
|
||||
// ============================================
|
||||
|
||||
// Get settings file path
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
/**
|
||||
* Generate .env file content from config
|
||||
*/
|
||||
const generateEnvContent = (
|
||||
config: Partial<ProjectEnvConfig>,
|
||||
existingContent?: string
|
||||
): string => {
|
||||
// Parse existing content to preserve comments and structure
|
||||
const existingVars = existingContent ? parseEnvFile(existingContent) : {};
|
||||
|
||||
// Update with new values
|
||||
if (config.claudeOAuthToken !== undefined) {
|
||||
existingVars['CLAUDE_CODE_OAUTH_TOKEN'] = config.claudeOAuthToken;
|
||||
}
|
||||
if (config.autoBuildModel !== undefined) {
|
||||
existingVars['AUTO_BUILD_MODEL'] = config.autoBuildModel;
|
||||
}
|
||||
if (config.linearApiKey !== undefined) {
|
||||
existingVars['LINEAR_API_KEY'] = config.linearApiKey;
|
||||
}
|
||||
if (config.linearTeamId !== undefined) {
|
||||
existingVars['LINEAR_TEAM_ID'] = config.linearTeamId;
|
||||
}
|
||||
if (config.linearProjectId !== undefined) {
|
||||
existingVars['LINEAR_PROJECT_ID'] = config.linearProjectId;
|
||||
}
|
||||
if (config.linearRealtimeSync !== undefined) {
|
||||
existingVars['LINEAR_REALTIME_SYNC'] = config.linearRealtimeSync ? 'true' : 'false';
|
||||
}
|
||||
// GitHub Integration
|
||||
if (config.githubToken !== undefined) {
|
||||
existingVars['GITHUB_TOKEN'] = config.githubToken;
|
||||
}
|
||||
if (config.githubRepo !== undefined) {
|
||||
existingVars['GITHUB_REPO'] = config.githubRepo;
|
||||
}
|
||||
if (config.githubAutoSync !== undefined) {
|
||||
existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false';
|
||||
}
|
||||
if (config.graphitiEnabled !== undefined) {
|
||||
existingVars['GRAPHITI_ENABLED'] = config.graphitiEnabled ? 'true' : 'false';
|
||||
}
|
||||
if (config.openaiApiKey !== undefined) {
|
||||
existingVars['OPENAI_API_KEY'] = config.openaiApiKey;
|
||||
}
|
||||
if (config.graphitiFalkorDbHost !== undefined) {
|
||||
existingVars['GRAPHITI_FALKORDB_HOST'] = config.graphitiFalkorDbHost;
|
||||
}
|
||||
if (config.graphitiFalkorDbPort !== undefined) {
|
||||
existingVars['GRAPHITI_FALKORDB_PORT'] = String(config.graphitiFalkorDbPort);
|
||||
}
|
||||
if (config.graphitiFalkorDbPassword !== undefined) {
|
||||
existingVars['GRAPHITI_FALKORDB_PASSWORD'] = config.graphitiFalkorDbPassword;
|
||||
}
|
||||
if (config.graphitiDatabase !== undefined) {
|
||||
existingVars['GRAPHITI_DATABASE'] = config.graphitiDatabase;
|
||||
}
|
||||
if (config.enableFancyUi !== undefined) {
|
||||
existingVars['ENABLE_FANCY_UI'] = config.enableFancyUi ? 'true' : 'false';
|
||||
}
|
||||
|
||||
// Generate content with sections
|
||||
let content = `# Auto Claude Framework Environment Variables
|
||||
# Managed by Auto Claude UI
|
||||
|
||||
# Claude Code OAuth Token (REQUIRED)
|
||||
CLAUDE_CODE_OAUTH_TOKEN=${existingVars['CLAUDE_CODE_OAUTH_TOKEN'] || ''}
|
||||
|
||||
# Model override (OPTIONAL)
|
||||
${existingVars['AUTO_BUILD_MODEL'] ? `AUTO_BUILD_MODEL=${existingVars['AUTO_BUILD_MODEL']}` : '# AUTO_BUILD_MODEL=claude-opus-4-5-20251101'}
|
||||
|
||||
# =============================================================================
|
||||
# LINEAR INTEGRATION (OPTIONAL)
|
||||
# =============================================================================
|
||||
${existingVars['LINEAR_API_KEY'] ? `LINEAR_API_KEY=${existingVars['LINEAR_API_KEY']}` : '# LINEAR_API_KEY='}
|
||||
${existingVars['LINEAR_TEAM_ID'] ? `LINEAR_TEAM_ID=${existingVars['LINEAR_TEAM_ID']}` : '# LINEAR_TEAM_ID='}
|
||||
${existingVars['LINEAR_PROJECT_ID'] ? `LINEAR_PROJECT_ID=${existingVars['LINEAR_PROJECT_ID']}` : '# LINEAR_PROJECT_ID='}
|
||||
${existingVars['LINEAR_REALTIME_SYNC'] !== undefined ? `LINEAR_REALTIME_SYNC=${existingVars['LINEAR_REALTIME_SYNC']}` : '# LINEAR_REALTIME_SYNC=false'}
|
||||
|
||||
# =============================================================================
|
||||
# GITHUB INTEGRATION (OPTIONAL)
|
||||
# =============================================================================
|
||||
${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}` : '# GITHUB_TOKEN='}
|
||||
${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'}
|
||||
${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'}
|
||||
|
||||
# =============================================================================
|
||||
# UI SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
${existingVars['ENABLE_FANCY_UI'] !== undefined ? `ENABLE_FANCY_UI=${existingVars['ENABLE_FANCY_UI']}` : '# ENABLE_FANCY_UI=true'}
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI MEMORY INTEGRATION (OPTIONAL)
|
||||
# =============================================================================
|
||||
${existingVars['GRAPHITI_ENABLED'] ? `GRAPHITI_ENABLED=${existingVars['GRAPHITI_ENABLED']}` : '# GRAPHITI_ENABLED=false'}
|
||||
${existingVars['OPENAI_API_KEY'] ? `OPENAI_API_KEY=${existingVars['OPENAI_API_KEY']}` : '# OPENAI_API_KEY='}
|
||||
${existingVars['GRAPHITI_FALKORDB_HOST'] ? `GRAPHITI_FALKORDB_HOST=${existingVars['GRAPHITI_FALKORDB_HOST']}` : '# GRAPHITI_FALKORDB_HOST=localhost'}
|
||||
${existingVars['GRAPHITI_FALKORDB_PORT'] ? `GRAPHITI_FALKORDB_PORT=${existingVars['GRAPHITI_FALKORDB_PORT']}` : '# GRAPHITI_FALKORDB_PORT=6380'}
|
||||
${existingVars['GRAPHITI_FALKORDB_PASSWORD'] ? `GRAPHITI_FALKORDB_PASSWORD=${existingVars['GRAPHITI_FALKORDB_PASSWORD']}` : '# GRAPHITI_FALKORDB_PASSWORD='}
|
||||
${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHITI_DATABASE']}` : '# GRAPHITI_DATABASE=auto_build_memory'}
|
||||
`;
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ENV_GET,
|
||||
async (_, projectId: string): Promise<IPCResult<ProjectEnvConfig>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
if (!project.autoBuildPath) {
|
||||
return { success: false, error: 'Project not initialized' };
|
||||
}
|
||||
|
||||
const envPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
|
||||
// Load global settings for fallbacks
|
||||
let globalSettings: AppSettings = { ...DEFAULT_APP_SETTINGS };
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
globalSettings = { ...globalSettings, ...JSON.parse(content) };
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Default config
|
||||
const config: ProjectEnvConfig = {
|
||||
claudeAuthStatus: 'not_configured',
|
||||
linearEnabled: false,
|
||||
githubEnabled: false,
|
||||
graphitiEnabled: false,
|
||||
enableFancyUi: true,
|
||||
claudeTokenIsGlobal: false,
|
||||
openaiKeyIsGlobal: false
|
||||
};
|
||||
|
||||
// Parse project-specific .env if it exists
|
||||
let vars: Record<string, string> = {};
|
||||
if (existsSync(envPath)) {
|
||||
try {
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
vars = parseEnvFile(content);
|
||||
} catch {
|
||||
// Continue with empty vars
|
||||
}
|
||||
}
|
||||
|
||||
// Claude OAuth Token: project-specific takes precedence, then global
|
||||
if (vars['CLAUDE_CODE_OAUTH_TOKEN']) {
|
||||
config.claudeOAuthToken = vars['CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
config.claudeAuthStatus = 'token_set';
|
||||
config.claudeTokenIsGlobal = false;
|
||||
} else if (globalSettings.globalClaudeOAuthToken) {
|
||||
config.claudeOAuthToken = globalSettings.globalClaudeOAuthToken;
|
||||
config.claudeAuthStatus = 'token_set';
|
||||
config.claudeTokenIsGlobal = true;
|
||||
}
|
||||
|
||||
if (vars['AUTO_BUILD_MODEL']) {
|
||||
config.autoBuildModel = vars['AUTO_BUILD_MODEL'];
|
||||
}
|
||||
|
||||
if (vars['LINEAR_API_KEY']) {
|
||||
config.linearEnabled = true;
|
||||
config.linearApiKey = vars['LINEAR_API_KEY'];
|
||||
}
|
||||
if (vars['LINEAR_TEAM_ID']) {
|
||||
config.linearTeamId = vars['LINEAR_TEAM_ID'];
|
||||
}
|
||||
if (vars['LINEAR_PROJECT_ID']) {
|
||||
config.linearProjectId = vars['LINEAR_PROJECT_ID'];
|
||||
}
|
||||
if (vars['LINEAR_REALTIME_SYNC']?.toLowerCase() === 'true') {
|
||||
config.linearRealtimeSync = true;
|
||||
}
|
||||
|
||||
// GitHub config
|
||||
if (vars['GITHUB_TOKEN']) {
|
||||
config.githubEnabled = true;
|
||||
config.githubToken = vars['GITHUB_TOKEN'];
|
||||
}
|
||||
if (vars['GITHUB_REPO']) {
|
||||
config.githubRepo = vars['GITHUB_REPO'];
|
||||
}
|
||||
if (vars['GITHUB_AUTO_SYNC']?.toLowerCase() === 'true') {
|
||||
config.githubAutoSync = true;
|
||||
}
|
||||
|
||||
if (vars['GRAPHITI_ENABLED']?.toLowerCase() === 'true') {
|
||||
config.graphitiEnabled = true;
|
||||
}
|
||||
|
||||
// OpenAI API Key: project-specific takes precedence, then global
|
||||
if (vars['OPENAI_API_KEY']) {
|
||||
config.openaiApiKey = vars['OPENAI_API_KEY'];
|
||||
config.openaiKeyIsGlobal = false;
|
||||
} else if (globalSettings.globalOpenAIApiKey) {
|
||||
config.openaiApiKey = globalSettings.globalOpenAIApiKey;
|
||||
config.openaiKeyIsGlobal = true;
|
||||
}
|
||||
|
||||
if (vars['GRAPHITI_FALKORDB_HOST']) {
|
||||
config.graphitiFalkorDbHost = vars['GRAPHITI_FALKORDB_HOST'];
|
||||
}
|
||||
if (vars['GRAPHITI_FALKORDB_PORT']) {
|
||||
config.graphitiFalkorDbPort = parseInt(vars['GRAPHITI_FALKORDB_PORT'], 10);
|
||||
}
|
||||
if (vars['GRAPHITI_FALKORDB_PASSWORD']) {
|
||||
config.graphitiFalkorDbPassword = vars['GRAPHITI_FALKORDB_PASSWORD'];
|
||||
}
|
||||
if (vars['GRAPHITI_DATABASE']) {
|
||||
config.graphitiDatabase = vars['GRAPHITI_DATABASE'];
|
||||
}
|
||||
|
||||
if (vars['ENABLE_FANCY_UI']?.toLowerCase() === 'false') {
|
||||
config.enableFancyUi = false;
|
||||
}
|
||||
|
||||
return { success: true, data: config };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ENV_UPDATE,
|
||||
async (_, projectId: string, config: Partial<ProjectEnvConfig>): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
if (!project.autoBuildPath) {
|
||||
return { success: false, error: 'Project not initialized' };
|
||||
}
|
||||
|
||||
const envPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
|
||||
try {
|
||||
// Read existing content if file exists
|
||||
let existingContent: string | undefined;
|
||||
if (existsSync(envPath)) {
|
||||
existingContent = readFileSync(envPath, 'utf-8');
|
||||
}
|
||||
|
||||
// Generate new content
|
||||
const newContent = generateEnvContent(config, existingContent);
|
||||
|
||||
// Write to file
|
||||
writeFileSync(envPath, newContent);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update .env file'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH,
|
||||
async (_, projectId: string): Promise<IPCResult<ClaudeAuthResult>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if Claude CLI is available and authenticated
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['--version'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
shell: true
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code: number | null) => {
|
||||
if (code === 0) {
|
||||
// Claude CLI is available, check if authenticated
|
||||
// Run a simple command that requires auth
|
||||
const authCheck = spawn('claude', ['api', '--help'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
shell: true
|
||||
});
|
||||
|
||||
authCheck.on('close', (authCode: number | null) => {
|
||||
resolve({
|
||||
success: true,
|
||||
authenticated: authCode === 0
|
||||
});
|
||||
});
|
||||
|
||||
authCheck.on('error', () => {
|
||||
resolve({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: 'Could not verify authentication'
|
||||
});
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: 'Claude CLI not found. Please install it first.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', () => {
|
||||
resolve({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: 'Claude CLI not found. Please install it first.'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check Claude auth'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP,
|
||||
async (_, projectId: string): Promise<IPCResult<ClaudeAuthResult>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Run claude setup-token which will open browser for OAuth
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['setup-token'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
shell: true,
|
||||
stdio: 'inherit' // This allows the terminal to handle the interactive auth
|
||||
});
|
||||
|
||||
proc.on('close', (code: number | null) => {
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
success: true,
|
||||
authenticated: true
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: 'Setup cancelled or failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err: Error) => {
|
||||
resolve({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to invoke Claude setup'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { readdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type { IPCResult, FileNode } from '../../shared/types';
|
||||
|
||||
// Directories to ignore when listing
|
||||
const IGNORED_DIRS = new Set([
|
||||
'node_modules', '.git', '__pycache__', 'dist', 'build',
|
||||
'.next', '.nuxt', 'coverage', '.cache', '.venv', 'venv',
|
||||
'.idea', '.vscode', 'out', '.turbo', '.auto-claude',
|
||||
'.worktrees', 'vendor', 'target', '.gradle', '.maven'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Register all file-related IPC handlers
|
||||
*/
|
||||
export function registerFileHandlers(): void {
|
||||
// ============================================
|
||||
// File Explorer Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.FILE_EXPLORER_LIST,
|
||||
async (_, dirPath: string): Promise<IPCResult<FileNode[]>> => {
|
||||
try {
|
||||
const entries = readdirSync(dirPath, { withFileTypes: true });
|
||||
|
||||
// Filter and map entries
|
||||
const nodes: FileNode[] = [];
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files (except .env which is often useful)
|
||||
if (entry.name.startsWith('.') && entry.name !== '.env') continue;
|
||||
// Skip ignored directories
|
||||
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
||||
|
||||
nodes.push({
|
||||
path: path.join(dirPath, entry.name),
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory()
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
nodes.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
return { success: true, data: nodes };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list directory'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants';
|
||||
import type { IPCResult, GitHubRepository, GitHubIssue, GitHubSyncStatus, GitHubImportResult, GitHubInvestigationResult, Project, Task, TaskMetadata, GitHubInvestigationStatus } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { projectStore } from '../project-store';
|
||||
import { AgentManager } from '../agent';
|
||||
import { parseEnvFile } from './utils';
|
||||
|
||||
|
||||
/**
|
||||
* Register all github-related IPC handlers
|
||||
*/
|
||||
export function registerGithubHandlers(
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// GitHub Integration Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Helper to get GitHub config from project env
|
||||
*/
|
||||
const getGitHubConfig = (project: Project): { token: string; repo: string } | null => {
|
||||
if (!project.autoBuildPath) return null;
|
||||
const envPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
if (!existsSync(envPath)) return null;
|
||||
|
||||
try {
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const vars = parseEnvFile(content);
|
||||
const token = vars['GITHUB_TOKEN'];
|
||||
const repo = vars['GITHUB_REPO'];
|
||||
|
||||
if (!token || !repo) return null;
|
||||
return { token, repo };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make a request to the GitHub API
|
||||
*/
|
||||
const githubFetch = async (
|
||||
token: string,
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<unknown> => {
|
||||
const url = endpoint.startsWith('http')
|
||||
? endpoint
|
||||
: `https://api.github.com${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText} - ${errorBody}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_CHECK_CONNECTION,
|
||||
async (_, projectId: string): Promise<IPCResult<GitHubSyncStatus>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
connected: false,
|
||||
error: 'No GitHub token or repository configured'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch repo info
|
||||
const repoData = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}`
|
||||
) as { full_name: string; description?: string };
|
||||
|
||||
// Count open issues
|
||||
const issuesData = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues?state=open&per_page=1`
|
||||
) as unknown[];
|
||||
|
||||
const openCount = Array.isArray(issuesData) ? issuesData.length : 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
connected: true,
|
||||
repoFullName: repoData.full_name,
|
||||
repoDescription: repoData.description,
|
||||
issueCount: openCount,
|
||||
lastSyncedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
connected: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to connect to GitHub'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_GET_REPOSITORIES,
|
||||
async (_, projectId: string): Promise<IPCResult<GitHubRepository[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
return { success: false, error: 'No GitHub token configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const repos = await githubFetch(
|
||||
config.token,
|
||||
'/user/repos?per_page=100&sort=updated'
|
||||
) as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description?: string;
|
||||
html_url: string;
|
||||
default_branch: string;
|
||||
private: boolean;
|
||||
owner: { login: string; avatar_url?: string };
|
||||
}>;
|
||||
|
||||
const result: GitHubRepository[] = repos.map(repo => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
description: repo.description,
|
||||
url: repo.html_url,
|
||||
defaultBranch: repo.default_branch,
|
||||
private: repo.private,
|
||||
owner: {
|
||||
login: repo.owner.login,
|
||||
avatarUrl: repo.owner.avatar_url
|
||||
}
|
||||
}));
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch repositories'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_GET_ISSUES,
|
||||
async (_, projectId: string, state: 'open' | 'closed' | 'all' = 'open'): Promise<IPCResult<GitHubIssue[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
return { success: false, error: 'No GitHub token or repository configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const issues = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues?state=${state}&per_page=100&sort=updated`
|
||||
) as Array<{
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string;
|
||||
state: 'open' | 'closed';
|
||||
labels: Array<{ id: number; name: string; color: string; description?: string }>;
|
||||
assignees: Array<{ login: string; avatar_url?: string }>;
|
||||
user: { login: string; avatar_url?: string };
|
||||
milestone?: { id: number; title: string; state: 'open' | 'closed' };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at?: string;
|
||||
comments: number;
|
||||
url: string;
|
||||
html_url: string;
|
||||
pull_request?: unknown;
|
||||
}>;
|
||||
|
||||
// Filter out pull requests
|
||||
const issuesOnly = issues.filter(issue => !issue.pull_request);
|
||||
|
||||
const result: GitHubIssue[] = issuesOnly.map(issue => ({
|
||||
id: issue.id,
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
body: issue.body,
|
||||
state: issue.state,
|
||||
labels: issue.labels,
|
||||
assignees: issue.assignees.map(a => ({
|
||||
login: a.login,
|
||||
avatarUrl: a.avatar_url
|
||||
})),
|
||||
author: {
|
||||
login: issue.user.login,
|
||||
avatarUrl: issue.user.avatar_url
|
||||
},
|
||||
milestone: issue.milestone,
|
||||
createdAt: issue.created_at,
|
||||
updatedAt: issue.updated_at,
|
||||
closedAt: issue.closed_at,
|
||||
commentsCount: issue.comments,
|
||||
url: issue.url,
|
||||
htmlUrl: issue.html_url,
|
||||
repoFullName: config.repo
|
||||
}));
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch issues'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_GET_ISSUE,
|
||||
async (_, projectId: string, issueNumber: number): Promise<IPCResult<GitHubIssue>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
return { success: false, error: 'No GitHub token or repository configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}`
|
||||
) as {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string;
|
||||
state: 'open' | 'closed';
|
||||
labels: Array<{ id: number; name: string; color: string; description?: string }>;
|
||||
assignees: Array<{ login: string; avatar_url?: string }>;
|
||||
user: { login: string; avatar_url?: string };
|
||||
milestone?: { id: number; title: string; state: 'open' | 'closed' };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at?: string;
|
||||
comments: number;
|
||||
url: string;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
const result: GitHubIssue = {
|
||||
id: issue.id,
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
body: issue.body,
|
||||
state: issue.state,
|
||||
labels: issue.labels,
|
||||
assignees: issue.assignees.map(a => ({
|
||||
login: a.login,
|
||||
avatarUrl: a.avatar_url
|
||||
})),
|
||||
author: {
|
||||
login: issue.user.login,
|
||||
avatarUrl: issue.user.avatar_url
|
||||
},
|
||||
milestone: issue.milestone,
|
||||
createdAt: issue.created_at,
|
||||
updatedAt: issue.updated_at,
|
||||
closedAt: issue.closed_at,
|
||||
commentsCount: issue.comments,
|
||||
url: issue.url,
|
||||
htmlUrl: issue.html_url,
|
||||
repoFullName: config.repo
|
||||
};
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch issue'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE,
|
||||
async (_, projectId: string, issueNumber: number) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR,
|
||||
projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR,
|
||||
projectId,
|
||||
'No GitHub token or repository configured'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send progress update: fetching issue
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'fetching',
|
||||
issueNumber,
|
||||
progress: 10,
|
||||
message: 'Fetching issue details...'
|
||||
} as GitHubInvestigationStatus
|
||||
);
|
||||
|
||||
// Fetch the issue
|
||||
const issue = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}`
|
||||
) as {
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string;
|
||||
labels: Array<{ name: string }>;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
// Fetch issue comments for more context
|
||||
const comments = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}/comments`
|
||||
) as Array<{ body: string; user: { login: string } }>;
|
||||
|
||||
// Build context for the AI investigation
|
||||
const issueContext = `
|
||||
# GitHub Issue #${issue.number}: ${issue.title}
|
||||
|
||||
${issue.body || 'No description provided.'}
|
||||
|
||||
${comments.length > 0 ? `## Comments (${comments.length}):
|
||||
${comments.map(c => `**${c.user.login}:** ${c.body}`).join('\n\n')}` : ''}
|
||||
|
||||
**Labels:** ${issue.labels.map(l => l.name).join(', ') || 'None'}
|
||||
**URL:** ${issue.html_url}
|
||||
`;
|
||||
|
||||
// Send progress update: analyzing
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'analyzing',
|
||||
issueNumber,
|
||||
progress: 30,
|
||||
message: 'AI is analyzing the issue...'
|
||||
} as GitHubInvestigationStatus
|
||||
);
|
||||
|
||||
// Build task description
|
||||
const taskDescription = `Investigate GitHub Issue #${issue.number}: ${issue.title}
|
||||
|
||||
${issueContext}
|
||||
|
||||
Please analyze this issue and provide:
|
||||
1. A brief summary of what the issue is about
|
||||
2. A proposed solution approach
|
||||
3. The files that would likely need to be modified
|
||||
4. Estimated complexity (simple/standard/complex)
|
||||
5. Acceptance criteria for resolving this issue`;
|
||||
|
||||
// Create proper spec directory
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
if (!existsSync(specsDir)) {
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Find next available spec number
|
||||
let specNumber = 1;
|
||||
const existingDirs = readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
const existingNumbers = existingDirs
|
||||
.map(name => {
|
||||
const match = name.match(/^(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter(n => n > 0);
|
||||
if (existingNumbers.length > 0) {
|
||||
specNumber = Math.max(...existingNumbers) + 1;
|
||||
}
|
||||
|
||||
// Create spec ID with zero-padded number and slugified title
|
||||
const slugifiedTitle = issue.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`;
|
||||
|
||||
// Create spec directory
|
||||
const specDir = path.join(specsDir, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Create initial implementation_plan.json
|
||||
const now = new Date().toISOString();
|
||||
const implementationPlan = {
|
||||
feature: issue.title,
|
||||
description: taskDescription,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
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'
|
||||
};
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2));
|
||||
|
||||
// Build metadata
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'github',
|
||||
githubIssueNumber: issue.number,
|
||||
githubUrl: issue.html_url,
|
||||
category: 'feature'
|
||||
};
|
||||
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2));
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata);
|
||||
|
||||
// Send progress update: creating task
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'creating_task',
|
||||
issueNumber,
|
||||
progress: 70,
|
||||
message: 'Creating task from investigation...'
|
||||
} as GitHubInvestigationStatus
|
||||
);
|
||||
|
||||
const investigationResult: GitHubInvestigationResult = {
|
||||
success: true,
|
||||
issueNumber,
|
||||
analysis: {
|
||||
summary: `Investigation of issue #${issueNumber}: ${issue.title}`,
|
||||
proposedSolution: 'Task has been created for AI agent to implement the solution.',
|
||||
affectedFiles: [],
|
||||
estimatedComplexity: 'standard',
|
||||
acceptanceCriteria: [
|
||||
`Issue #${issueNumber} requirements are met`,
|
||||
'All existing tests pass',
|
||||
'New functionality is tested'
|
||||
]
|
||||
},
|
||||
taskId: specId
|
||||
};
|
||||
|
||||
// Send completion
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'complete',
|
||||
issueNumber,
|
||||
progress: 100,
|
||||
message: 'Investigation complete!'
|
||||
} as GitHubInvestigationStatus
|
||||
);
|
||||
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE,
|
||||
projectId,
|
||||
investigationResult
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR,
|
||||
projectId,
|
||||
error instanceof Error ? error.message : 'Failed to investigate issue'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_IMPORT_ISSUES,
|
||||
async (_, projectId: string, issueNumbers: number[]): Promise<IPCResult<GitHubImportResult>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
return { success: false, error: 'No GitHub token or repository configured' };
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
const tasks: Task[] = [];
|
||||
|
||||
// Set up specs directory
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
if (!existsSync(specsDir)) {
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const issueNumber of issueNumbers) {
|
||||
try {
|
||||
const issue = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}`
|
||||
) as {
|
||||
number: number;
|
||||
title: string;
|
||||
body?: string;
|
||||
labels: Array<{ name: string }>;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
const labels = issue.labels.map(l => l.name).join(', ');
|
||||
const description = `# ${issue.title}
|
||||
|
||||
**GitHub Issue:** [#${issue.number}](${issue.html_url})
|
||||
${labels ? `**Labels:** ${labels}` : ''}
|
||||
|
||||
## Description
|
||||
|
||||
${issue.body || 'No description provided.'}
|
||||
`;
|
||||
|
||||
// Find next available spec number
|
||||
let specNumber = 1;
|
||||
const existingDirs = readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
const existingNumbers = existingDirs
|
||||
.map(name => {
|
||||
const match = name.match(/^(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter(n => n > 0);
|
||||
if (existingNumbers.length > 0) {
|
||||
specNumber = Math.max(...existingNumbers) + 1;
|
||||
}
|
||||
|
||||
// Create spec ID with zero-padded number and slugified title
|
||||
const slugifiedTitle = issue.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`;
|
||||
|
||||
// Create spec directory
|
||||
const specDir = path.join(specsDir, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Create initial implementation_plan.json
|
||||
const now = new Date().toISOString();
|
||||
const implementationPlan = {
|
||||
feature: issue.title,
|
||||
description: description,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2));
|
||||
|
||||
// Create requirements.json
|
||||
const requirements = {
|
||||
task_description: description,
|
||||
workflow_type: 'feature'
|
||||
};
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2));
|
||||
|
||||
// Build metadata
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'github',
|
||||
githubIssueNumber: issue.number,
|
||||
githubUrl: issue.html_url,
|
||||
category: 'feature'
|
||||
};
|
||||
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2));
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
agentManager.startSpecCreation(specId, project.path, description, specDir, metadata);
|
||||
imported++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
errors.push(`Failed to import #${issueNumber}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
success: failed === 0,
|
||||
imported,
|
||||
failed,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
tasks
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a GitHub release using the gh CLI
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_CREATE_RELEASE,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
version: string,
|
||||
releaseNotes: string,
|
||||
options?: { draft?: boolean; prerelease?: boolean }
|
||||
): Promise<IPCResult<{ url: string }>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if gh CLI is available
|
||||
// Use 'where' on Windows, 'which' on Unix
|
||||
try {
|
||||
const checkCmd = process.platform === 'win32' ? 'where gh' : 'which gh';
|
||||
execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' });
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'GitHub CLI (gh) not found. Please install it: https://cli.github.com/'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
try {
|
||||
execSync('gh auth status', { cwd: project.path, encoding: 'utf-8', stdio: 'pipe' });
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated with GitHub. Run "gh auth login" in terminal first.'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare tag name (ensure v prefix)
|
||||
const tag = version.startsWith('v') ? version : `v${version}`;
|
||||
|
||||
// Build gh release command
|
||||
const args = ['release', 'create', tag, '--title', tag, '--notes', releaseNotes];
|
||||
if (options?.draft) args.push('--draft');
|
||||
if (options?.prerelease) args.push('--prerelease');
|
||||
|
||||
// Create the release
|
||||
const output = execSync(`gh ${args.map(a => `"${a.replace(/"/g, '\\"')}"`).join(' ')}`, {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
}).trim();
|
||||
|
||||
// Output is typically the release URL
|
||||
const releaseUrl = output || `https://github.com/releases/tag/${tag}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { url: releaseUrl }
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to create release';
|
||||
// Try to extract more useful error message from stderr
|
||||
if (error && typeof error === 'object' && 'stderr' in error) {
|
||||
return { success: false, error: String(error.stderr) || errorMsg };
|
||||
}
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants';
|
||||
import type { IPCResult, IdeationSession, IdeationConfig, IdeationGenerationStatus, IdeationStatus, Task, ImplementationPlan, TaskMetadata, TaskCategory, TaskImpact } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
|
||||
import { projectStore } from '../project-store';
|
||||
import { fileWatcher } from '../file-watcher';
|
||||
import { AgentManager } from '../agent';
|
||||
|
||||
|
||||
/**
|
||||
* Register all ideation-related IPC handlers
|
||||
*/
|
||||
export function registerIdeationHandlers(
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Ideation Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Transform an idea from snake_case (Python backend) to camelCase (TypeScript frontend)
|
||||
*/
|
||||
const transformIdeaFromSnakeCase = (idea: Record<string, unknown>) => {
|
||||
const base = {
|
||||
id: idea.id as string,
|
||||
type: idea.type as string,
|
||||
title: idea.title as string,
|
||||
description: idea.description as string,
|
||||
rationale: idea.rationale as string,
|
||||
status: idea.status as string || 'draft',
|
||||
createdAt: idea.created_at ? new Date(idea.created_at as string) : new Date()
|
||||
};
|
||||
|
||||
if (idea.type === 'code_improvements') {
|
||||
return {
|
||||
...base,
|
||||
buildsUpon: idea.builds_upon || idea.buildsUpon || [],
|
||||
estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'small',
|
||||
affectedFiles: idea.affected_files || idea.affectedFiles || [],
|
||||
existingPatterns: idea.existing_patterns || idea.existingPatterns || [],
|
||||
implementationApproach: idea.implementation_approach || idea.implementationApproach || ''
|
||||
};
|
||||
} else if (idea.type === 'ui_ux_improvements') {
|
||||
return {
|
||||
...base,
|
||||
category: idea.category || 'usability',
|
||||
affectedComponents: idea.affected_components || idea.affectedComponents || [],
|
||||
screenshots: idea.screenshots || [],
|
||||
currentState: idea.current_state || idea.currentState || '',
|
||||
proposedChange: idea.proposed_change || idea.proposedChange || '',
|
||||
userBenefit: idea.user_benefit || idea.userBenefit || ''
|
||||
};
|
||||
} else if (idea.type === 'documentation_gaps') {
|
||||
return {
|
||||
...base,
|
||||
category: idea.category || 'readme',
|
||||
targetAudience: idea.target_audience || idea.targetAudience || 'developers',
|
||||
affectedAreas: idea.affected_areas || idea.affectedAreas || [],
|
||||
currentDocumentation: idea.current_documentation || idea.currentDocumentation || '',
|
||||
proposedContent: idea.proposed_content || idea.proposedContent || '',
|
||||
priority: idea.priority || 'medium',
|
||||
estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'small'
|
||||
};
|
||||
} else if (idea.type === 'security_hardening') {
|
||||
return {
|
||||
...base,
|
||||
category: idea.category || 'configuration',
|
||||
severity: idea.severity || 'medium',
|
||||
affectedFiles: idea.affected_files || idea.affectedFiles || [],
|
||||
vulnerability: idea.vulnerability || '',
|
||||
currentRisk: idea.current_risk || idea.currentRisk || '',
|
||||
remediation: idea.remediation || '',
|
||||
references: idea.references || [],
|
||||
compliance: idea.compliance || []
|
||||
};
|
||||
} else if (idea.type === 'performance_optimizations') {
|
||||
return {
|
||||
...base,
|
||||
category: idea.category || 'runtime',
|
||||
impact: idea.impact || 'medium',
|
||||
affectedAreas: idea.affected_areas || idea.affectedAreas || [],
|
||||
currentMetric: idea.current_metric || idea.currentMetric || '',
|
||||
expectedImprovement: idea.expected_improvement || idea.expectedImprovement || '',
|
||||
implementation: idea.implementation || '',
|
||||
tradeoffs: idea.tradeoffs || '',
|
||||
estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'medium'
|
||||
};
|
||||
} else if (idea.type === 'code_quality') {
|
||||
return {
|
||||
...base,
|
||||
category: idea.category || 'code_smells',
|
||||
severity: idea.severity || 'minor',
|
||||
affectedFiles: idea.affected_files || idea.affectedFiles || [],
|
||||
currentState: idea.current_state || idea.currentState || '',
|
||||
proposedChange: idea.proposed_change || idea.proposedChange || '',
|
||||
codeExample: idea.code_example || idea.codeExample || '',
|
||||
bestPractice: idea.best_practice || idea.bestPractice || '',
|
||||
metrics: idea.metrics || {},
|
||||
estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'medium',
|
||||
breakingChange: idea.breaking_change ?? idea.breakingChange ?? false,
|
||||
prerequisites: idea.prerequisites || []
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.IDEATION_GET,
|
||||
async (_, projectId: string): Promise<IPCResult<IdeationSession | null>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const ideationPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.IDEATION_DIR,
|
||||
AUTO_BUILD_PATHS.IDEATION_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(ideationPath)) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ideationPath, 'utf-8');
|
||||
const rawIdeation = JSON.parse(content);
|
||||
|
||||
// Transform snake_case to camelCase for frontend
|
||||
const session: IdeationSession = {
|
||||
id: rawIdeation.id || `ideation-${Date.now()}`,
|
||||
projectId,
|
||||
config: {
|
||||
enabledTypes: rawIdeation.config?.enabled_types || rawIdeation.config?.enabledTypes || [],
|
||||
includeRoadmapContext: rawIdeation.config?.include_roadmap_context ?? rawIdeation.config?.includeRoadmapContext ?? true,
|
||||
includeKanbanContext: rawIdeation.config?.include_kanban_context ?? rawIdeation.config?.includeKanbanContext ?? true,
|
||||
maxIdeasPerType: rawIdeation.config?.max_ideas_per_type || rawIdeation.config?.maxIdeasPerType || 5
|
||||
},
|
||||
ideas: (rawIdeation.ideas || []).map((idea: Record<string, unknown>) =>
|
||||
transformIdeaFromSnakeCase(idea)
|
||||
),
|
||||
projectContext: {
|
||||
existingFeatures: rawIdeation.project_context?.existing_features || rawIdeation.projectContext?.existingFeatures || [],
|
||||
techStack: rawIdeation.project_context?.tech_stack || rawIdeation.projectContext?.techStack || [],
|
||||
targetAudience: rawIdeation.project_context?.target_audience || rawIdeation.projectContext?.targetAudience,
|
||||
plannedFeatures: rawIdeation.project_context?.planned_features || rawIdeation.projectContext?.plannedFeatures || []
|
||||
},
|
||||
generatedAt: rawIdeation.generated_at ? new Date(rawIdeation.generated_at) : new Date(),
|
||||
updatedAt: rawIdeation.updated_at ? new Date(rawIdeation.updated_at) : new Date()
|
||||
};
|
||||
|
||||
return { success: true, data: session };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to read ideation'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.IDEATION_GENERATE,
|
||||
(_, projectId: string, config: IdeationConfig) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.IDEATION_ERROR,
|
||||
projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start ideation generation via agent manager
|
||||
agentManager.startIdeationGeneration(projectId, project.path, config, false);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.IDEATION_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'analyzing',
|
||||
progress: 10,
|
||||
message: 'Analyzing project structure...'
|
||||
} as IdeationGenerationStatus
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.IDEATION_REFRESH,
|
||||
(_, projectId: string, config: IdeationConfig) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.IDEATION_ERROR,
|
||||
projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start ideation regeneration with refresh flag
|
||||
agentManager.startIdeationGeneration(projectId, project.path, config, true);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.IDEATION_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'analyzing',
|
||||
progress: 10,
|
||||
message: 'Refreshing ideation...'
|
||||
} as IdeationGenerationStatus
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Stop ideation generation
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.IDEATION_STOP,
|
||||
async (_, projectId: string): Promise<IPCResult> => {
|
||||
const mainWindow = getMainWindow();
|
||||
const wasStopped = agentManager.stopIdeation(projectId);
|
||||
|
||||
if (wasStopped && mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId);
|
||||
}
|
||||
|
||||
return { success: wasStopped };
|
||||
}
|
||||
);
|
||||
|
||||
// Dismiss all ideas
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.IDEATION_DISMISS_ALL,
|
||||
async (_, projectId: string): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const ideationPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.IDEATION_DIR,
|
||||
AUTO_BUILD_PATHS.IDEATION_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(ideationPath)) {
|
||||
return { success: false, error: 'Ideation not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ideationPath, 'utf-8');
|
||||
const ideation = JSON.parse(content);
|
||||
|
||||
// Dismiss all ideas that are not already dismissed or converted
|
||||
let dismissedCount = 0;
|
||||
ideation.ideas?.forEach((idea: { status: string }) => {
|
||||
if (idea.status !== 'dismissed' && idea.status !== 'converted') {
|
||||
idea.status = 'dismissed';
|
||||
dismissedCount++;
|
||||
}
|
||||
});
|
||||
ideation.updated_at = new Date().toISOString();
|
||||
|
||||
writeFileSync(ideationPath, JSON.stringify(ideation, null, 2));
|
||||
|
||||
return { success: true, data: { dismissedCount } };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to dismiss all ideas'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.IDEATION_UPDATE_IDEA,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
ideaId: string,
|
||||
status: IdeationStatus
|
||||
): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const ideationPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.IDEATION_DIR,
|
||||
AUTO_BUILD_PATHS.IDEATION_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(ideationPath)) {
|
||||
return { success: false, error: 'Ideation not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ideationPath, 'utf-8');
|
||||
const ideation = JSON.parse(content);
|
||||
|
||||
// Find and update the idea
|
||||
const idea = ideation.ideas?.find((i: { id: string }) => i.id === ideaId);
|
||||
if (!idea) {
|
||||
return { success: false, error: 'Idea not found' };
|
||||
}
|
||||
|
||||
idea.status = status;
|
||||
ideation.updated_at = new Date().toISOString();
|
||||
|
||||
writeFileSync(ideationPath, JSON.stringify(ideation, null, 2));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update idea'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.IDEATION_DISMISS,
|
||||
async (_, projectId: string, ideaId: string): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const ideationPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.IDEATION_DIR,
|
||||
AUTO_BUILD_PATHS.IDEATION_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(ideationPath)) {
|
||||
return { success: false, error: 'Ideation not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ideationPath, 'utf-8');
|
||||
const ideation = JSON.parse(content);
|
||||
|
||||
// Find and dismiss the idea
|
||||
const idea = ideation.ideas?.find((i: { id: string }) => i.id === ideaId);
|
||||
if (!idea) {
|
||||
return { success: false, error: 'Idea not found' };
|
||||
}
|
||||
|
||||
idea.status = 'dismissed';
|
||||
ideation.updated_at = new Date().toISOString();
|
||||
|
||||
writeFileSync(ideationPath, JSON.stringify(ideation, null, 2));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to dismiss idea'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.IDEATION_CONVERT_TO_TASK,
|
||||
async (_, projectId: string, ideaId: string): Promise<IPCResult<Task>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const ideationPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.IDEATION_DIR,
|
||||
AUTO_BUILD_PATHS.IDEATION_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(ideationPath)) {
|
||||
return { success: false, error: 'Ideation not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ideationPath, 'utf-8');
|
||||
const ideation = JSON.parse(content);
|
||||
|
||||
// Find the idea
|
||||
const idea = ideation.ideas?.find((i: { id: string }) => i.id === ideaId);
|
||||
if (!idea) {
|
||||
return { success: false, error: 'Idea not found' };
|
||||
}
|
||||
|
||||
// Generate spec ID by finding next available number
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
|
||||
// Ensure specs directory exists
|
||||
if (!existsSync(specsDir)) {
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Find next spec number
|
||||
let nextNum = 1;
|
||||
try {
|
||||
const existingSpecs = readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => {
|
||||
const match = d.name.match(/^(\d+)-/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter(n => n > 0);
|
||||
if (existingSpecs.length > 0) {
|
||||
nextNum = Math.max(...existingSpecs) + 1;
|
||||
}
|
||||
} catch {
|
||||
// Use default 1
|
||||
}
|
||||
|
||||
// Create spec directory name from idea title
|
||||
const slugifiedTitle = idea.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const specId = `${String(nextNum).padStart(3, '0')}-${slugifiedTitle}`;
|
||||
const specDir = path.join(specsDir, specId);
|
||||
|
||||
// Create the spec directory
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Build task description based on idea type
|
||||
let taskDescription = `# ${idea.title}\n\n`;
|
||||
taskDescription += `${idea.description}\n\n`;
|
||||
taskDescription += `## Rationale\n${idea.rationale}\n\n`;
|
||||
|
||||
// Note: high_value_features removed - strategic features belong to Roadmap
|
||||
// low_hanging_fruit renamed to code_improvements
|
||||
if (idea.type === 'code_improvements') {
|
||||
if (idea.builds_upon?.length) {
|
||||
taskDescription += `## Builds Upon\n${idea.builds_upon.map((b: string) => `- ${b}`).join('\n')}\n\n`;
|
||||
}
|
||||
if (idea.implementation_approach) {
|
||||
taskDescription += `## Implementation Approach\n${idea.implementation_approach}\n\n`;
|
||||
}
|
||||
if (idea.affected_files?.length) {
|
||||
taskDescription += `## Affected Files\n${idea.affected_files.map((f: string) => `- ${f}`).join('\n')}\n\n`;
|
||||
}
|
||||
if (idea.existing_patterns?.length) {
|
||||
taskDescription += `## Patterns to Follow\n${idea.existing_patterns.map((p: string) => `- ${p}`).join('\n')}\n\n`;
|
||||
}
|
||||
} else if (idea.type === 'ui_ux_improvements') {
|
||||
taskDescription += `## Category\n${idea.category}\n\n`;
|
||||
taskDescription += `## Current State\n${idea.current_state}\n\n`;
|
||||
taskDescription += `## Proposed Change\n${idea.proposed_change}\n\n`;
|
||||
taskDescription += `## User Benefit\n${idea.user_benefit}\n\n`;
|
||||
if (idea.affected_components?.length) {
|
||||
taskDescription += `## Affected Components\n${idea.affected_components.map((c: string) => `- ${c}`).join('\n')}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial implementation_plan.json so task shows in kanban immediately
|
||||
const initialPlan: ImplementationPlan = {
|
||||
feature: idea.title,
|
||||
description: idea.description,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
status: 'backlog',
|
||||
planStatus: 'pending',
|
||||
phases: [],
|
||||
workflow_type: 'development',
|
||||
services_involved: [],
|
||||
final_acceptance: [],
|
||||
spec_file: 'spec.md'
|
||||
};
|
||||
writeFileSync(
|
||||
path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN),
|
||||
JSON.stringify(initialPlan, null, 2)
|
||||
);
|
||||
|
||||
// Create initial spec.md with the task description
|
||||
const specContent = `# ${idea.title}
|
||||
|
||||
## Overview
|
||||
|
||||
${idea.description}
|
||||
|
||||
## Rationale
|
||||
|
||||
${idea.rationale}
|
||||
|
||||
---
|
||||
*This spec was created from ideation and is pending detailed specification.*
|
||||
`;
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), specContent);
|
||||
|
||||
// Update idea with converted status
|
||||
idea.status = 'converted';
|
||||
idea.linked_task_id = specId;
|
||||
ideation.updated_at = new Date().toISOString();
|
||||
writeFileSync(ideationPath, JSON.stringify(ideation, null, 2));
|
||||
|
||||
// Build metadata from idea type
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'ideation',
|
||||
ideationType: idea.type,
|
||||
ideaId: idea.id,
|
||||
rationale: idea.rationale
|
||||
};
|
||||
|
||||
// Map idea type to task category
|
||||
// Note: high_value_features removed, low_hanging_fruit renamed to code_improvements
|
||||
const ideaTypeToCategory: Record<string, TaskCategory> = {
|
||||
'code_improvements': 'feature',
|
||||
'ui_ux_improvements': 'ui_ux',
|
||||
'documentation_gaps': 'documentation',
|
||||
'security_hardening': 'security',
|
||||
'performance_optimizations': 'performance',
|
||||
'code_quality': 'refactoring'
|
||||
};
|
||||
metadata.category = ideaTypeToCategory[idea.type] || 'feature';
|
||||
|
||||
// Extract type-specific metadata
|
||||
// Note: high_value_features removed - strategic features belong to Roadmap
|
||||
// low_hanging_fruit renamed to code_improvements
|
||||
if (idea.type === 'code_improvements') {
|
||||
metadata.estimatedEffort = idea.estimated_effort;
|
||||
metadata.complexity = idea.estimated_effort; // trivial/small/medium/large/complex
|
||||
metadata.affectedFiles = idea.affected_files;
|
||||
} else if (idea.type === 'ui_ux_improvements') {
|
||||
metadata.uiuxCategory = idea.category;
|
||||
metadata.affectedFiles = idea.affected_components;
|
||||
metadata.problemSolved = idea.current_state;
|
||||
} else if (idea.type === 'documentation_gaps') {
|
||||
metadata.estimatedEffort = idea.estimated_effort;
|
||||
metadata.priority = idea.priority;
|
||||
metadata.targetAudience = idea.target_audience;
|
||||
metadata.affectedFiles = idea.affected_areas;
|
||||
} else if (idea.type === 'security_hardening') {
|
||||
metadata.securitySeverity = idea.severity;
|
||||
metadata.impact = idea.severity as TaskImpact; // Map severity to impact
|
||||
metadata.priority = idea.severity === 'critical' ? 'urgent' : idea.severity === 'high' ? 'high' : 'medium';
|
||||
metadata.affectedFiles = idea.affected_files;
|
||||
} else if (idea.type === 'performance_optimizations') {
|
||||
metadata.performanceCategory = idea.category;
|
||||
metadata.impact = idea.impact as TaskImpact;
|
||||
metadata.estimatedEffort = idea.estimated_effort;
|
||||
metadata.affectedFiles = idea.affected_areas;
|
||||
} else if (idea.type === 'code_quality') {
|
||||
metadata.codeQualitySeverity = idea.severity;
|
||||
metadata.estimatedEffort = idea.estimated_effort;
|
||||
metadata.affectedFiles = idea.affected_files;
|
||||
metadata.priority = idea.severity === 'critical' ? 'urgent' : idea.severity === 'major' ? 'high' : 'medium';
|
||||
}
|
||||
|
||||
// Save metadata to a separate file for persistence
|
||||
const metadataPath = path.join(specDir, 'task_metadata.json');
|
||||
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
// Task is created in Planning (backlog) - user must manually start it
|
||||
// Previously auto-started spec creation here, but user should control when to start
|
||||
|
||||
// Create task object to return
|
||||
const task: Task = {
|
||||
id: specId,
|
||||
specId: specId,
|
||||
projectId,
|
||||
title: idea.title,
|
||||
description: taskDescription,
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
logs: [],
|
||||
metadata,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
return { success: true, data: task };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to convert idea to task'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* IPC Handlers Module Index
|
||||
*
|
||||
* This module exports a single setup function that registers all IPC handlers
|
||||
* organized by domain into separate handler modules.
|
||||
*/
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { AgentManager } from '../agent';
|
||||
import { TerminalManager } from '../terminal-manager';
|
||||
import { PythonEnvManager } from '../python-env-manager';
|
||||
|
||||
// Import all handler registration functions
|
||||
import { registerProjectHandlers } from './project-handlers';
|
||||
import { registerTaskHandlers } from './task-handlers';
|
||||
import { registerTerminalHandlers } from './terminal-handlers';
|
||||
import { registerAgenteventsHandlers } from './agent-events-handlers';
|
||||
import { registerSettingsHandlers } from './settings-handlers';
|
||||
import { registerFileHandlers } from './file-handlers';
|
||||
import { registerRoadmapHandlers } from './roadmap-handlers';
|
||||
import { registerContextHandlers } from './context-handlers';
|
||||
import { registerEnvHandlers } from './env-handlers';
|
||||
import { registerLinearHandlers } from './linear-handlers';
|
||||
import { registerGithubHandlers } from './github-handlers';
|
||||
import { registerAutobuildSourceHandlers } from './autobuild-source-handlers';
|
||||
import { registerIdeationHandlers } from './ideation-handlers';
|
||||
import { registerChangelogHandlers } from './changelog-handlers';
|
||||
import { registerInsightsHandlers } from './insights-handlers';
|
||||
|
||||
/**
|
||||
* Setup all IPC handlers across all domains
|
||||
*
|
||||
* @param agentManager - The agent manager instance
|
||||
* @param terminalManager - The terminal manager instance
|
||||
* @param getMainWindow - Function to get the main BrowserWindow
|
||||
* @param pythonEnvManager - The Python environment manager instance
|
||||
*/
|
||||
export function setupIpcHandlers(
|
||||
agentManager: AgentManager,
|
||||
terminalManager: TerminalManager,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
pythonEnvManager: PythonEnvManager
|
||||
): void {
|
||||
// Project handlers (including Python environment setup)
|
||||
registerProjectHandlers(pythonEnvManager, agentManager, getMainWindow);
|
||||
|
||||
// Task handlers
|
||||
registerTaskHandlers(agentManager, pythonEnvManager, getMainWindow);
|
||||
|
||||
// Terminal and Claude profile handlers
|
||||
registerTerminalHandlers(terminalManager, getMainWindow);
|
||||
|
||||
// Agent event handlers (event forwarding from agent manager to renderer)
|
||||
registerAgenteventsHandlers(agentManager, getMainWindow);
|
||||
|
||||
// Settings and dialog handlers
|
||||
registerSettingsHandlers(agentManager, getMainWindow);
|
||||
|
||||
// File explorer handlers
|
||||
registerFileHandlers();
|
||||
|
||||
// Roadmap handlers
|
||||
registerRoadmapHandlers(agentManager, getMainWindow);
|
||||
|
||||
// Context and memory handlers
|
||||
registerContextHandlers(getMainWindow);
|
||||
|
||||
// Environment configuration handlers
|
||||
registerEnvHandlers(getMainWindow);
|
||||
|
||||
// Linear integration handlers
|
||||
registerLinearHandlers(agentManager, getMainWindow);
|
||||
|
||||
// GitHub integration handlers
|
||||
registerGithubHandlers(agentManager, getMainWindow);
|
||||
|
||||
// Auto-build source update handlers
|
||||
registerAutobuildSourceHandlers(getMainWindow);
|
||||
|
||||
// Ideation handlers
|
||||
registerIdeationHandlers(agentManager, getMainWindow);
|
||||
|
||||
// Changelog handlers
|
||||
registerChangelogHandlers(getMainWindow);
|
||||
|
||||
// Insights handlers
|
||||
registerInsightsHandlers(getMainWindow);
|
||||
|
||||
console.log('[IPC] All handler modules registered successfully');
|
||||
}
|
||||
|
||||
// Re-export all individual registration functions for potential custom usage
|
||||
export {
|
||||
registerProjectHandlers,
|
||||
registerTaskHandlers,
|
||||
registerTerminalHandlers,
|
||||
registerAgenteventsHandlers,
|
||||
registerSettingsHandlers,
|
||||
registerFileHandlers,
|
||||
registerRoadmapHandlers,
|
||||
registerContextHandlers,
|
||||
registerEnvHandlers,
|
||||
registerLinearHandlers,
|
||||
registerGithubHandlers,
|
||||
registerAutobuildSourceHandlers,
|
||||
registerIdeationHandlers,
|
||||
registerChangelogHandlers,
|
||||
registerInsightsHandlers
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
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, Task, TaskMetadata } from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
import { insightsService } from '../insights-service';
|
||||
|
||||
/**
|
||||
* Register all insights-related IPC handlers
|
||||
*/
|
||||
export function registerInsightsHandlers(
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Insights Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_GET_SESSION,
|
||||
async (_, projectId: string): Promise<IPCResult<InsightsSession | null>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const session = insightsService.loadSession(projectId, project.path);
|
||||
return { success: true, data: session };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.INSIGHTS_SEND_MESSAGE,
|
||||
async (_, projectId: string, message: string) => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_ERROR, projectId, 'Project not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: Python environment initialization should be handled by insightsService
|
||||
// or added here with proper dependency injection if needed
|
||||
insightsService.sendMessage(projectId, project.path, message);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_CLEAR_SESSION,
|
||||
async (_, projectId: string): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
insightsService.clearSession(projectId, project.path);
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_CREATE_TASK,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
title: string,
|
||||
description: string,
|
||||
metadata?: TaskMetadata
|
||||
): Promise<IPCResult<Task>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
if (!project.autoBuildPath) {
|
||||
return { success: false, error: 'Auto Claude not initialized for this project' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate a unique spec ID based on existing specs
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
|
||||
// Find next available spec number
|
||||
let specNumber = 1;
|
||||
if (existsSync(specsDir)) {
|
||||
const existingDirs = readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
|
||||
const existingNumbers = existingDirs
|
||||
.map(name => {
|
||||
const match = name.match(/^(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter(n => n > 0);
|
||||
|
||||
if (existingNumbers.length > 0) {
|
||||
specNumber = Math.max(...existingNumbers) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Create spec ID with zero-padded number and slugified title
|
||||
const slugifiedTitle = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`;
|
||||
|
||||
// Create spec directory
|
||||
const specDir = path.join(specsDir, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Build metadata with source type
|
||||
const taskMetadata: TaskMetadata = {
|
||||
sourceType: 'insights',
|
||||
...metadata
|
||||
};
|
||||
|
||||
// Create initial implementation_plan.json
|
||||
const now = new Date().toISOString();
|
||||
const implementationPlan = {
|
||||
feature: title,
|
||||
description: description,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2));
|
||||
|
||||
// Save task metadata
|
||||
const metadataPath = path.join(specDir, 'task_metadata.json');
|
||||
writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2));
|
||||
|
||||
// Create the task object
|
||||
const task: Task = {
|
||||
id: specId,
|
||||
specId: specId,
|
||||
projectId,
|
||||
title,
|
||||
description,
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
logs: [],
|
||||
metadata: taskMetadata,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
return { success: true, data: task };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create task'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// List all sessions for a project
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_LIST_SESSIONS,
|
||||
async (_, projectId: string): Promise<IPCResult<InsightsSessionSummary[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const sessions = insightsService.listSessions(project.path);
|
||||
return { success: true, data: sessions };
|
||||
}
|
||||
);
|
||||
|
||||
// Create a new session
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_NEW_SESSION,
|
||||
async (_, projectId: string): Promise<IPCResult<InsightsSession>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const session = insightsService.createNewSession(projectId, project.path);
|
||||
return { success: true, data: session };
|
||||
}
|
||||
);
|
||||
|
||||
// Switch to a different session
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_SWITCH_SESSION,
|
||||
async (_, projectId: string, sessionId: string): Promise<IPCResult<InsightsSession | null>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const session = insightsService.switchSession(projectId, project.path, sessionId);
|
||||
return { success: true, data: session };
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a session
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_DELETE_SESSION,
|
||||
async (_, projectId: string, sessionId: string): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const success = insightsService.deleteSession(projectId, project.path, sessionId);
|
||||
if (success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Failed to delete session' };
|
||||
}
|
||||
);
|
||||
|
||||
// Rename a session
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_RENAME_SESSION,
|
||||
async (_, projectId: string, sessionId: string, newTitle: string): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const success = insightsService.renameSession(project.path, sessionId, newTitle);
|
||||
if (success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Failed to rename session' };
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
|
||||
import type { IPCResult, LinearIssue, LinearTeam, LinearProject, LinearImportResult, LinearSyncStatus, Project, Task, TaskMetadata } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { projectStore } from '../project-store';
|
||||
import { parseEnvFile } from './utils';
|
||||
|
||||
|
||||
import { AgentManager } from '../agent';
|
||||
|
||||
/**
|
||||
* Register all linear-related IPC handlers
|
||||
*/
|
||||
export function registerLinearHandlers(
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Linear Integration Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Helper to get Linear API key from project env
|
||||
*/
|
||||
const getLinearApiKey = (project: Project): string | null => {
|
||||
if (!project.autoBuildPath) return null;
|
||||
const envPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
if (!existsSync(envPath)) return null;
|
||||
|
||||
try {
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const vars = parseEnvFile(content);
|
||||
return vars['LINEAR_API_KEY'] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Make a request to the Linear API
|
||||
*/
|
||||
const linearGraphQL = async (
|
||||
apiKey: string,
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
): Promise<unknown> => {
|
||||
const response = await fetch('https://api.linear.app/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': apiKey
|
||||
},
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Linear API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0]?.message || 'Linear API error');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.LINEAR_CHECK_CONNECTION,
|
||||
async (_, projectId: string): Promise<IPCResult<LinearSyncStatus>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const apiKey = getLinearApiKey(project);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
connected: false,
|
||||
error: 'No Linear API key configured'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query {
|
||||
viewer {
|
||||
id
|
||||
name
|
||||
}
|
||||
teams {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
key
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await linearGraphQL(apiKey, query) as {
|
||||
viewer: { id: string; name: string };
|
||||
teams: { nodes: Array<{ id: string; name: string; key: string }> };
|
||||
};
|
||||
|
||||
// Get issue count for the first team
|
||||
let issueCount = 0;
|
||||
let teamName: string | undefined;
|
||||
|
||||
if (data.teams.nodes.length > 0) {
|
||||
teamName = data.teams.nodes[0].name;
|
||||
const countQuery = `
|
||||
query($teamId: String!) {
|
||||
team(id: $teamId) {
|
||||
issues {
|
||||
totalCount: nodes { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
// Get approximate count
|
||||
const issuesQuery = `
|
||||
query($teamId: String!) {
|
||||
issues(filter: { team: { id: { eq: $teamId } } }, first: 0) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Simple count estimation - get first 250 issues
|
||||
const countData = await linearGraphQL(apiKey, `
|
||||
query($teamId: String!) {
|
||||
issues(filter: { team: { id: { eq: $teamId } } }, first: 250) {
|
||||
nodes { id }
|
||||
}
|
||||
}
|
||||
`, { teamId: data.teams.nodes[0].id }) as {
|
||||
issues: { nodes: Array<{ id: string }> };
|
||||
};
|
||||
issueCount = countData.issues.nodes.length;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
connected: true,
|
||||
teamName,
|
||||
issueCount,
|
||||
lastSyncedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
connected: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to connect to Linear'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.LINEAR_GET_TEAMS,
|
||||
async (_, projectId: string): Promise<IPCResult<LinearTeam[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const apiKey = getLinearApiKey(project);
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'No Linear API key configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query {
|
||||
teams {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
key
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await linearGraphQL(apiKey, query) as {
|
||||
teams: { nodes: LinearTeam[] };
|
||||
};
|
||||
|
||||
return { success: true, data: data.teams.nodes };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch teams'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.LINEAR_GET_PROJECTS,
|
||||
async (_, projectId: string, teamId: string): Promise<IPCResult<LinearProject[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const apiKey = getLinearApiKey(project);
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'No Linear API key configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const query = `
|
||||
query($teamId: String!) {
|
||||
team(id: $teamId) {
|
||||
projects {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await linearGraphQL(apiKey, query, { teamId }) as {
|
||||
team: { projects: { nodes: LinearProject[] } };
|
||||
};
|
||||
|
||||
return { success: true, data: data.team.projects.nodes };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch projects'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.LINEAR_GET_ISSUES,
|
||||
async (_, projectId: string, teamId?: string, linearProjectId?: string): Promise<IPCResult<LinearIssue[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const apiKey = getLinearApiKey(project);
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'No Linear API key configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Build filter based on provided parameters
|
||||
const filters: string[] = [];
|
||||
if (teamId) {
|
||||
filters.push(`team: { id: { eq: "${teamId}" } }`);
|
||||
}
|
||||
if (linearProjectId) {
|
||||
filters.push(`project: { id: { eq: "${linearProjectId}" } }`);
|
||||
}
|
||||
|
||||
const filterClause = filters.length > 0 ? `filter: { ${filters.join(', ')} }` : '';
|
||||
|
||||
const query = `
|
||||
query {
|
||||
issues(${filterClause}, first: 250, orderBy: updatedAt) {
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
state {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
priority
|
||||
priorityLabel
|
||||
labels {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
color
|
||||
}
|
||||
}
|
||||
assignee {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
project {
|
||||
id
|
||||
name
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await linearGraphQL(apiKey, query) as {
|
||||
issues: {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
state: { id: string; name: string; type: string };
|
||||
priority: number;
|
||||
priorityLabel: string;
|
||||
labels: { nodes: Array<{ id: string; name: string; color: string }> };
|
||||
assignee?: { id: string; name: string; email: string };
|
||||
project?: { id: string; name: string };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
// Transform to our LinearIssue format
|
||||
const issues: LinearIssue[] = data.issues.nodes.map(issue => ({
|
||||
...issue,
|
||||
labels: issue.labels.nodes
|
||||
}));
|
||||
|
||||
return { success: true, data: issues };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch issues'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.LINEAR_IMPORT_ISSUES,
|
||||
async (_, projectId: string, issueIds: string[]): Promise<IPCResult<LinearImportResult>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const apiKey = getLinearApiKey(project);
|
||||
if (!apiKey) {
|
||||
return { success: false, error: 'No Linear API key configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
// First, fetch the full details of selected issues
|
||||
const query = `
|
||||
query($ids: [String!]!) {
|
||||
issues(filter: { id: { in: $ids } }) {
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
state {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
priority
|
||||
priorityLabel
|
||||
labels {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
color
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await linearGraphQL(apiKey, query, { ids: issueIds }) as {
|
||||
issues: {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
state: { id: string; name: string; type: string };
|
||||
priority: number;
|
||||
priorityLabel: string;
|
||||
labels: { nodes: Array<{ id: string; name: string; color: string }> };
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Set up specs directory
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
if (!existsSync(specsDir)) {
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create tasks for each imported issue
|
||||
for (const issue of data.issues.nodes) {
|
||||
try {
|
||||
// Build description from Linear issue
|
||||
const labels = issue.labels.nodes.map(l => l.name).join(', ');
|
||||
const description = `# ${issue.title}
|
||||
|
||||
**Linear Issue:** [${issue.identifier}](${issue.url})
|
||||
**Priority:** ${issue.priorityLabel}
|
||||
**Status:** ${issue.state.name}
|
||||
${labels ? `**Labels:** ${labels}` : ''}
|
||||
|
||||
## Description
|
||||
|
||||
${issue.description || 'No description provided.'}
|
||||
`;
|
||||
|
||||
// Find next available spec number
|
||||
let specNumber = 1;
|
||||
const existingDirs = readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
const existingNumbers = existingDirs
|
||||
.map(name => {
|
||||
const match = name.match(/^(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter(n => n > 0);
|
||||
if (existingNumbers.length > 0) {
|
||||
specNumber = Math.max(...existingNumbers) + 1;
|
||||
}
|
||||
|
||||
// Create spec ID with zero-padded number and slugified title
|
||||
const slugifiedTitle = issue.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`;
|
||||
|
||||
// Create spec directory
|
||||
const specDir = path.join(specsDir, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Create initial implementation_plan.json
|
||||
const now = new Date().toISOString();
|
||||
const implementationPlan = {
|
||||
feature: issue.title,
|
||||
description: description,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2));
|
||||
|
||||
// Create requirements.json
|
||||
const requirements = {
|
||||
task_description: description,
|
||||
workflow_type: 'feature'
|
||||
};
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2));
|
||||
|
||||
// Build metadata
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'linear',
|
||||
linearIssueId: issue.id,
|
||||
linearIdentifier: issue.identifier,
|
||||
linearUrl: issue.url,
|
||||
category: 'feature'
|
||||
};
|
||||
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2));
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
agentManager.startSpecCreation(specId, project.path, description, specDir, metadata);
|
||||
|
||||
imported++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
errors.push(`Failed to import ${issue.identifier}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
success: failed === 0,
|
||||
imported,
|
||||
failed,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to import issues'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { ipcMain, app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSettings,
|
||||
IPCResult,
|
||||
InitializationResult,
|
||||
AutoBuildVersionInfo
|
||||
} from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
import {
|
||||
initializeProject,
|
||||
isInitialized,
|
||||
hasLocalSource
|
||||
} from '../project-initializer';
|
||||
import { PythonEnvManager, type PythonEnvStatus } from '../python-env-manager';
|
||||
import { AgentManager } from '../agent';
|
||||
import { changelogService } from '../changelog-service';
|
||||
import { insightsService } from '../insights-service';
|
||||
import { titleGenerator } from '../title-generator';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
/**
|
||||
* Auto-detect the auto-claude source path relative to the app location
|
||||
* In dev: auto-claude-ui/../auto-claude
|
||||
* In prod: Could be bundled or configured
|
||||
*/
|
||||
const detectAutoBuildSourcePath = (): string | null => {
|
||||
// Try relative to app directory (works in dev and if repo structure is maintained)
|
||||
// __dirname in main process points to out/main in dev
|
||||
const possiblePaths = [
|
||||
// Dev mode: from out/main -> ../../../auto-claude (sibling to auto-claude-ui)
|
||||
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
|
||||
// Alternative: from app root (useful in some packaged scenarios)
|
||||
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
||||
// If running from repo root
|
||||
path.resolve(process.cwd(), 'auto-claude'),
|
||||
// Try one more level up (in case of different build output structure)
|
||||
path.resolve(__dirname, '..', '..', 'auto-claude')
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the configured auto-claude source path from settings, or auto-detect
|
||||
*/
|
||||
const getAutoBuildSourcePath = (): string | null => {
|
||||
// First check if manually configured
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
if (settings.autoBuildPath && existsSync(settings.autoBuildPath)) {
|
||||
return settings.autoBuildPath;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to auto-detect
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect from app location
|
||||
return detectAutoBuildSourcePath();
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure all Python-dependent services with the managed Python path
|
||||
*/
|
||||
const configureServicesWithPython = (
|
||||
pythonPath: string,
|
||||
autoBuildPath: string,
|
||||
agentManager: AgentManager
|
||||
): void => {
|
||||
console.log('[IPC] Configuring services with Python:', pythonPath);
|
||||
agentManager.configure(pythonPath, autoBuildPath);
|
||||
changelogService.configure(pythonPath, autoBuildPath);
|
||||
insightsService.configure(pythonPath, autoBuildPath);
|
||||
titleGenerator.configure(pythonPath, autoBuildPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the Python environment and configure services
|
||||
*/
|
||||
const initializePythonEnvironment = async (
|
||||
pythonEnvManager: PythonEnvManager,
|
||||
agentManager: AgentManager
|
||||
): Promise<PythonEnvStatus> => {
|
||||
const autoBuildSource = getAutoBuildSourcePath();
|
||||
if (!autoBuildSource) {
|
||||
console.log('[IPC] Auto-build source not found, skipping Python env init');
|
||||
return {
|
||||
ready: false,
|
||||
pythonPath: null,
|
||||
venvExists: false,
|
||||
depsInstalled: false,
|
||||
error: 'Auto-build source not found'
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[IPC] Initializing Python environment...');
|
||||
const status = await pythonEnvManager.initialize(autoBuildSource);
|
||||
|
||||
if (status.ready && status.pythonPath) {
|
||||
configureServicesWithPython(status.pythonPath, autoBuildSource, agentManager);
|
||||
}
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register all project-related IPC handlers
|
||||
*/
|
||||
export function registerProjectHandlers(
|
||||
pythonEnvManager: PythonEnvManager,
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Project Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PROJECT_ADD,
|
||||
async (_, projectPath: string): Promise<IPCResult<Project>> => {
|
||||
try {
|
||||
// Validate path exists
|
||||
if (!existsSync(projectPath)) {
|
||||
return { success: false, error: 'Directory does not exist' };
|
||||
}
|
||||
|
||||
const project = projectStore.addProject(projectPath);
|
||||
return { success: true, data: project };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PROJECT_REMOVE,
|
||||
async (_, projectId: string): Promise<IPCResult> => {
|
||||
const success = projectStore.removeProject(projectId);
|
||||
return { success };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PROJECT_LIST,
|
||||
async (): Promise<IPCResult<Project[]>> => {
|
||||
// Validate that .auto-claude folders still exist for all projects
|
||||
// If a folder was deleted, reset autoBuildPath so UI prompts for reinitialization
|
||||
const resetIds = projectStore.validateProjects();
|
||||
if (resetIds.length > 0) {
|
||||
console.log('[IPC] PROJECT_LIST: Detected missing .auto-claude folders for', resetIds.length, 'project(s)');
|
||||
}
|
||||
|
||||
const projects = projectStore.getProjects();
|
||||
console.log('[IPC] PROJECT_LIST returning', projects.length, 'projects');
|
||||
return { success: true, data: projects };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PROJECT_UPDATE_SETTINGS,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
settings: Partial<ProjectSettings>
|
||||
): Promise<IPCResult> => {
|
||||
const project = projectStore.updateProjectSettings(projectId, settings);
|
||||
if (project) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Project Initialization Operations
|
||||
// ============================================
|
||||
|
||||
// Set up Python environment status events
|
||||
pythonEnvManager.on('status', (message: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('python-env:status', message);
|
||||
}
|
||||
});
|
||||
|
||||
pythonEnvManager.on('error', (error: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('python-env:error', error);
|
||||
}
|
||||
});
|
||||
|
||||
pythonEnvManager.on('ready', (pythonPath: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('python-env:ready', pythonPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Python environment on startup (non-blocking)
|
||||
initializePythonEnvironment(pythonEnvManager, agentManager).then((status) => {
|
||||
console.log('[IPC] Python environment initialized:', status);
|
||||
});
|
||||
|
||||
// IPC handler to get Python environment status
|
||||
ipcMain.handle(
|
||||
'python-env:get-status',
|
||||
async (): Promise<IPCResult<PythonEnvStatus>> => {
|
||||
const status = await pythonEnvManager.getStatus();
|
||||
return { success: true, data: status };
|
||||
}
|
||||
);
|
||||
|
||||
// IPC handler to reinitialize Python environment
|
||||
ipcMain.handle(
|
||||
'python-env:reinitialize',
|
||||
async (): Promise<IPCResult<PythonEnvStatus>> => {
|
||||
const status = await initializePythonEnvironment(pythonEnvManager, agentManager);
|
||||
return { success: status.ready, data: status, error: status.error };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PROJECT_INITIALIZE,
|
||||
async (_, projectId: string): Promise<IPCResult<InitializationResult>> => {
|
||||
try {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const result = initializeProject(project.path);
|
||||
|
||||
if (result.success) {
|
||||
// Update project's autoBuildPath
|
||||
projectStore.updateAutoBuildPath(projectId, '.auto-claude');
|
||||
}
|
||||
|
||||
return { success: result.success, data: result, error: result.error };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PROJECT_UPDATE_AUTOBUILD is deprecated - .auto-claude only contains data, no code to update
|
||||
// Kept for API compatibility, returns success immediately
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PROJECT_UPDATE_AUTOBUILD,
|
||||
async (_, projectId: string): Promise<IPCResult<InitializationResult>> => {
|
||||
try {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// Nothing to update - .auto-claude only contains data directories
|
||||
// The framework runs from the source repo
|
||||
return { success: true, data: { success: true } };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PROJECT_CHECK_VERSION now just checks if project is initialized
|
||||
// Version tracking for .auto-claude is removed since it only contains data
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.PROJECT_CHECK_VERSION,
|
||||
async (_, projectId: string): Promise<IPCResult<AutoBuildVersionInfo>> => {
|
||||
try {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
isInitialized: isInitialized(project.path),
|
||||
updateAvailable: false // No updates for .auto-claude - it's just data
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Check if project has local auto-claude source (is dev project)
|
||||
ipcMain.handle(
|
||||
'project:has-local-source',
|
||||
async (_, projectId: string): Promise<IPCResult<boolean>> => {
|
||||
try {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
return { success: true, data: hasLocalSource(project.path) };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants';
|
||||
import type { IPCResult, Roadmap, RoadmapFeature, RoadmapFeatureStatus, RoadmapGenerationStatus, Task, TaskMetadata } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { projectStore } from '../project-store';
|
||||
import { fileWatcher } from '../file-watcher';
|
||||
import { AgentManager } from '../agent';
|
||||
|
||||
|
||||
/**
|
||||
* Register all roadmap-related IPC handlers
|
||||
*/
|
||||
export function registerRoadmapHandlers(
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Roadmap Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_GET,
|
||||
async (_, projectId: string): Promise<IPCResult<Roadmap | null>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.ROADMAP_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(roadmapPath)) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(roadmapPath, 'utf-8');
|
||||
const rawRoadmap = JSON.parse(content);
|
||||
|
||||
// Transform snake_case to camelCase for frontend
|
||||
const roadmap: Roadmap = {
|
||||
id: rawRoadmap.id || `roadmap-${Date.now()}`,
|
||||
projectId,
|
||||
projectName: rawRoadmap.project_name || project.name,
|
||||
version: rawRoadmap.version || '1.0',
|
||||
vision: rawRoadmap.vision || '',
|
||||
targetAudience: {
|
||||
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',
|
||||
features: phase.features || [],
|
||||
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
|
||||
}))
|
||||
})),
|
||||
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',
|
||||
phaseId: feature.phase_id,
|
||||
dependencies: feature.dependencies || [],
|
||||
status: feature.status || 'idea',
|
||||
acceptanceCriteria: feature.acceptance_criteria || [],
|
||||
userStories: feature.user_stories || [],
|
||||
linkedSpecId: feature.linked_spec_id
|
||||
})),
|
||||
status: rawRoadmap.status || 'draft',
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_GENERATE,
|
||||
(_, projectId: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_ERROR,
|
||||
projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start roadmap generation via agent manager
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, false);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'analyzing',
|
||||
progress: 10,
|
||||
message: 'Analyzing project structure...'
|
||||
} as RoadmapGenerationStatus
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_REFRESH,
|
||||
(_, projectId: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_ERROR,
|
||||
projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start roadmap regeneration with refresh flag
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, true);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'analyzing',
|
||||
progress: 10,
|
||||
message: 'Refreshing roadmap...'
|
||||
} as RoadmapGenerationStatus
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_UPDATE_FEATURE,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
status: RoadmapFeatureStatus
|
||||
): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.ROADMAP_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(roadmapPath)) {
|
||||
return { success: false, error: 'Roadmap not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
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' };
|
||||
}
|
||||
|
||||
feature.status = status;
|
||||
roadmap.metadata = roadmap.metadata || {};
|
||||
roadmap.metadata.updated_at = new Date().toISOString();
|
||||
|
||||
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update feature'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
featureId: string
|
||||
): Promise<IPCResult<Task>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.ROADMAP_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(roadmapPath)) {
|
||||
return { success: false, error: 'Roadmap not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
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' };
|
||||
}
|
||||
|
||||
// Build task description from feature
|
||||
const taskDescription = `# ${feature.title}
|
||||
|
||||
${feature.description}
|
||||
|
||||
## Rationale
|
||||
${feature.rationale || 'N/A'}
|
||||
|
||||
## User Stories
|
||||
${(feature.user_stories || []).map((s: string) => `- ${s}`).join('\n') || 'N/A'}
|
||||
|
||||
## Acceptance Criteria
|
||||
${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n') || 'N/A'}
|
||||
`;
|
||||
|
||||
// Generate proper spec directory (like task creation)
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
|
||||
// Ensure specs directory exists
|
||||
if (!existsSync(specsDir)) {
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Find next available spec number
|
||||
let specNumber = 1;
|
||||
const existingDirs = existsSync(specsDir)
|
||||
? readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
: [];
|
||||
const existingNumbers = existingDirs
|
||||
.map(name => {
|
||||
const match = name.match(/^(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter(n => n > 0);
|
||||
if (existingNumbers.length > 0) {
|
||||
specNumber = Math.max(...existingNumbers) + 1;
|
||||
}
|
||||
|
||||
// Create spec ID with zero-padded number and slugified title
|
||||
const slugifiedTitle = feature.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`;
|
||||
|
||||
// Create spec directory
|
||||
const specDir = path.join(specsDir, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Create initial implementation_plan.json
|
||||
const now = new Date().toISOString();
|
||||
const implementationPlan = {
|
||||
feature: feature.title,
|
||||
description: taskDescription,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
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'
|
||||
};
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2));
|
||||
|
||||
// Build metadata
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'roadmap',
|
||||
featureId: feature.id,
|
||||
category: 'feature'
|
||||
};
|
||||
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2));
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata);
|
||||
|
||||
// Update feature with linked spec
|
||||
feature.status = 'planned';
|
||||
feature.linked_spec_id = specId;
|
||||
roadmap.metadata = roadmap.metadata || {};
|
||||
roadmap.metadata.updated_at = new Date().toISOString();
|
||||
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
|
||||
|
||||
// Create task object
|
||||
const task: Task = {
|
||||
id: specId,
|
||||
specId: specId,
|
||||
projectId,
|
||||
title: feature.title,
|
||||
description: taskDescription,
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
logs: [],
|
||||
metadata,
|
||||
createdAt: 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 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-complete', (projectId: string, roadmap: Roadmap) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(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);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
// ============================================
|
||||
// Roadmap Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_GET,
|
||||
async (_, projectId: string): Promise<IPCResult<Roadmap | null>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.ROADMAP_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(roadmapPath)) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(roadmapPath, 'utf-8');
|
||||
const rawRoadmap = JSON.parse(content);
|
||||
|
||||
// Transform snake_case to camelCase for frontend
|
||||
const roadmap: Roadmap = {
|
||||
id: rawRoadmap.id || `roadmap-${Date.now()}`,
|
||||
projectId,
|
||||
projectName: rawRoadmap.project_name || project.name,
|
||||
version: rawRoadmap.version || '1.0',
|
||||
vision: rawRoadmap.vision || '',
|
||||
targetAudience: {
|
||||
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',
|
||||
features: phase.features || [],
|
||||
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
|
||||
}))
|
||||
})),
|
||||
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',
|
||||
phaseId: feature.phase_id,
|
||||
dependencies: feature.dependencies || [],
|
||||
status: feature.status || 'idea',
|
||||
acceptanceCriteria: feature.acceptance_criteria || [],
|
||||
userStories: feature.user_stories || [],
|
||||
linkedSpecId: feature.linked_spec_id
|
||||
})),
|
||||
status: rawRoadmap.status || 'draft',
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_GENERATE,
|
||||
(_, projectId: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_ERROR,
|
||||
projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start roadmap generation via agent manager
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, false);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'analyzing',
|
||||
progress: 10,
|
||||
message: 'Analyzing project structure...'
|
||||
} as RoadmapGenerationStatus
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_REFRESH,
|
||||
(_, projectId: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_ERROR,
|
||||
projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start roadmap regeneration with refresh flag
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, true);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'analyzing',
|
||||
progress: 10,
|
||||
message: 'Refreshing roadmap...'
|
||||
} as RoadmapGenerationStatus
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_UPDATE_FEATURE,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
status: RoadmapFeatureStatus
|
||||
): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.ROADMAP_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(roadmapPath)) {
|
||||
return { success: false, error: 'Roadmap not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
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' };
|
||||
}
|
||||
|
||||
feature.status = status;
|
||||
roadmap.metadata = roadmap.metadata || {};
|
||||
roadmap.metadata.updated_at = new Date().toISOString();
|
||||
|
||||
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update feature'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
featureId: string
|
||||
): Promise<IPCResult<Task>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.ROADMAP_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(roadmapPath)) {
|
||||
return { success: false, error: 'Roadmap not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
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' };
|
||||
}
|
||||
|
||||
// Build task description from feature
|
||||
const taskDescription = `# ${feature.title}
|
||||
|
||||
${feature.description}
|
||||
|
||||
## Rationale
|
||||
${feature.rationale || 'N/A'}
|
||||
|
||||
## User Stories
|
||||
${(feature.user_stories || []).map((s: string) => `- ${s}`).join('\n') || 'N/A'}
|
||||
|
||||
## Acceptance Criteria
|
||||
${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n') || 'N/A'}
|
||||
`;
|
||||
|
||||
// Generate proper spec directory (like task creation)
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
|
||||
// Ensure specs directory exists
|
||||
if (!existsSync(specsDir)) {
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Find next available spec number
|
||||
let specNumber = 1;
|
||||
const existingDirs = existsSync(specsDir)
|
||||
? readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
: [];
|
||||
const existingNumbers = existingDirs
|
||||
.map(name => {
|
||||
const match = name.match(/^(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter(n => n > 0);
|
||||
if (existingNumbers.length > 0) {
|
||||
specNumber = Math.max(...existingNumbers) + 1;
|
||||
}
|
||||
|
||||
// Create spec ID with zero-padded number and slugified title
|
||||
const slugifiedTitle = feature.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`;
|
||||
|
||||
// Create spec directory
|
||||
const specDir = path.join(specsDir, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Create initial implementation_plan.json
|
||||
const now = new Date().toISOString();
|
||||
const implementationPlan = {
|
||||
feature: feature.title,
|
||||
description: taskDescription,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
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'
|
||||
};
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2));
|
||||
|
||||
// Build metadata
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'roadmap',
|
||||
featureId: feature.id,
|
||||
category: 'feature'
|
||||
};
|
||||
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2));
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata);
|
||||
|
||||
// Update feature with linked spec
|
||||
feature.status = 'planned';
|
||||
feature.linked_spec_id = specId;
|
||||
roadmap.metadata = roadmap.metadata || {};
|
||||
roadmap.metadata.updated_at = new Date().toISOString();
|
||||
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
|
||||
|
||||
// Create task object
|
||||
const task: Task = {
|
||||
id: specId,
|
||||
specId: specId,
|
||||
projectId,
|
||||
title: feature.title,
|
||||
description: taskDescription,
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
logs: [],
|
||||
metadata,
|
||||
createdAt: 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 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-complete', (projectId: string, roadmap: Roadmap) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(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);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Context Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_GET,
|
||||
async (_, projectId: string): Promise<IPCResult<ProjectContextData>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Load project index
|
||||
let projectIndex: ProjectIndex | null = null;
|
||||
const indexPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX);
|
||||
if (existsSync(indexPath)) {
|
||||
const content = readFileSync(indexPath, 'utf-8');
|
||||
projectIndex = JSON.parse(content);
|
||||
}
|
||||
|
||||
// Load graphiti state from most recent spec or project root
|
||||
let memoryState: GraphitiMemoryState | null = null;
|
||||
let memoryStatus: GraphitiMemoryStatus = {
|
||||
enabled: false,
|
||||
available: false,
|
||||
reason: 'Graphiti not configured'
|
||||
};
|
||||
|
||||
// Check for graphiti state in specs
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
if (existsSync(specsDir)) {
|
||||
const specDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
})
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const specDir of specDirs) {
|
||||
const statePath = path.join(specsDir, specDir, AUTO_BUILD_PATHS.GRAPHITI_STATE);
|
||||
if (existsSync(statePath)) {
|
||||
const stateContent = readFileSync(statePath, 'utf-8');
|
||||
memoryState = JSON.parse(stateContent);
|
||||
|
||||
// If we found a state, update memory status
|
||||
if (memoryState?.initialized) {
|
||||
memoryStatus = {
|
||||
enabled: true,
|
||||
available: true,
|
||||
database: memoryState.database || 'auto_build_memory',
|
||||
host: process.env.GRAPHITI_FALKORDB_HOST || 'localhost',
|
||||
port: parseInt(process.env.GRAPHITI_FALKORDB_PORT || '6380', 10)
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check environment for Graphiti config if not found in specs
|
||||
if (!memoryState) {
|
||||
// Load project .env file and global settings to check for Graphiti config
|
||||
let projectEnvVars: Record<string, string> = {};
|
||||
if (project.autoBuildPath) {
|
||||
const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
if (existsSync(projectEnvPath)) {
|
||||
try {
|
||||
const envContent = readFileSync(projectEnvPath, 'utf-8');
|
||||
// Parse .env file inline - handle both Unix and Windows line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
projectEnvVars[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue with empty vars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load global settings for OpenAI API key fallback
|
||||
let globalOpenAIKey: string | undefined;
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const settingsContent = readFileSync(settingsPath, 'utf-8');
|
||||
const globalSettings = JSON.parse(settingsContent);
|
||||
globalOpenAIKey = globalSettings.globalOpenAIApiKey;
|
||||
} catch {
|
||||
// Continue without global settings
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Graphiti config: project .env > process.env
|
||||
const graphitiEnabled =
|
||||
projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' ||
|
||||
process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true';
|
||||
|
||||
// Check for OpenAI key: project .env > global settings > process.env
|
||||
const hasOpenAI =
|
||||
!!projectEnvVars['OPENAI_API_KEY'] ||
|
||||
!!globalOpenAIKey ||
|
||||
!!process.env.OPENAI_API_KEY;
|
||||
|
||||
// Get Graphiti connection details from project .env or process.env
|
||||
const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost';
|
||||
const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10);
|
||||
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory';
|
||||
|
||||
if (graphitiEnabled && hasOpenAI) {
|
||||
memoryStatus = {
|
||||
enabled: true,
|
||||
available: true,
|
||||
host: graphitiHost,
|
||||
port: graphitiPort,
|
||||
database: graphitiDatabase
|
||||
};
|
||||
} else if (graphitiEnabled && !hasOpenAI) {
|
||||
memoryStatus = {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reason: 'OPENAI_API_KEY not set (required for Graphiti embeddings)'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent memories from file-based memory (session insights)
|
||||
const recentMemories: MemoryEpisode[] = [];
|
||||
if (existsSync(specsDir)) {
|
||||
const recentSpecDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
})
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 10); // Last 10 specs
|
||||
|
||||
for (const specDir of recentSpecDirs) {
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
// Load session insights from session_insights subdirectory
|
||||
const sessionInsightsDir = path.join(memoryDir, 'session_insights');
|
||||
if (existsSync(sessionInsightsDir)) {
|
||||
const sessionFiles = readdirSync(sessionInsightsDir)
|
||||
.filter((f: string) => f.startsWith('session_') && f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const sessionFile of sessionFiles.slice(0, 3)) {
|
||||
try {
|
||||
const sessionPath = path.join(sessionInsightsDir, sessionFile);
|
||||
const sessionContent = readFileSync(sessionPath, 'utf-8');
|
||||
const sessionData = JSON.parse(sessionContent);
|
||||
|
||||
// Session files have: session_number, timestamp, subtasks_completed,
|
||||
// discoveries, what_worked, what_failed, recommendations_for_next_session
|
||||
if (sessionData.session_number !== undefined) {
|
||||
recentMemories.push({
|
||||
id: `${specDir}-${sessionFile}`,
|
||||
type: 'session_insight',
|
||||
timestamp: sessionData.timestamp || new Date().toISOString(),
|
||||
content: JSON.stringify({
|
||||
discoveries: sessionData.discoveries,
|
||||
what_worked: sessionData.what_worked,
|
||||
what_failed: sessionData.what_failed,
|
||||
recommendations: sessionData.recommendations_for_next_session,
|
||||
subtasks_completed: sessionData.subtasks_completed
|
||||
}, null, 2),
|
||||
session_number: sessionData.session_number
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also load codebase_map.json as a memory item
|
||||
const codebaseMapPath = path.join(memoryDir, 'codebase_map.json');
|
||||
if (existsSync(codebaseMapPath)) {
|
||||
try {
|
||||
const mapContent = readFileSync(codebaseMapPath, 'utf-8');
|
||||
const mapData = JSON.parse(mapContent);
|
||||
if (mapData.discovered_files && Object.keys(mapData.discovered_files).length > 0) {
|
||||
recentMemories.push({
|
||||
id: `${specDir}-codebase_map`,
|
||||
type: 'codebase_map',
|
||||
timestamp: mapData.last_updated || new Date().toISOString(),
|
||||
content: JSON.stringify(mapData.discovered_files, null, 2),
|
||||
session_number: undefined
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
projectIndex,
|
||||
memoryStatus,
|
||||
memoryState,
|
||||
recentMemories: recentMemories.slice(0, 20),
|
||||
isLoading: false
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load project context'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_REFRESH_INDEX,
|
||||
async (_, projectId: string): Promise<IPCResult<ProjectIndex>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Run the analyzer script to regenerate project_index.json
|
||||
const autoBuildSource = getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Auto-build source path not configured'
|
||||
};
|
||||
}
|
||||
|
||||
const analyzerPath = path.join(autoBuildSource, 'analyzer.py');
|
||||
const indexOutputPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX);
|
||||
|
||||
// Run analyzer
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn('python', [
|
||||
analyzerPath,
|
||||
'--project-dir', project.path,
|
||||
'--output', indexOutputPath
|
||||
], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env }
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Analyzer exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
|
||||
// Read the new index
|
||||
if (existsSync(indexOutputPath)) {
|
||||
const content = readFileSync(indexOutputPath, 'utf-8');
|
||||
const projectIndex = JSON.parse(content);
|
||||
return { success: true, data: projectIndex };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to generate project index' };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to refresh project index'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_MEMORY_STATUS,
|
||||
async (_, projectId: string): Promise<IPCResult<GraphitiMemoryStatus>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// Load project .env file to check for Graphiti config
|
||||
let projectEnvVars: Record<string, string> = {};
|
||||
if (project.autoBuildPath) {
|
||||
const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
if (existsSync(projectEnvPath)) {
|
||||
try {
|
||||
const envContent = readFileSync(projectEnvPath, 'utf-8');
|
||||
// Parse .env file inline - handle both Unix and Windows line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
projectEnvVars[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue with empty vars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load global settings for OpenAI API key fallback
|
||||
let globalOpenAIKey: string | undefined;
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const settingsContent = readFileSync(settingsPath, 'utf-8');
|
||||
const globalSettings = JSON.parse(settingsContent);
|
||||
globalOpenAIKey = globalSettings.globalOpenAIApiKey;
|
||||
} catch {
|
||||
// Continue without global settings
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Graphiti config: project .env > process.env
|
||||
const graphitiEnabled =
|
||||
projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' ||
|
||||
process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true';
|
||||
|
||||
// Check for OpenAI key: project .env > global settings > process.env
|
||||
const hasOpenAI =
|
||||
!!projectEnvVars['OPENAI_API_KEY'] ||
|
||||
!!globalOpenAIKey ||
|
||||
!!process.env.OPENAI_API_KEY;
|
||||
|
||||
// Get Graphiti connection details from project .env or process.env
|
||||
const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost';
|
||||
const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10);
|
||||
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory';
|
||||
|
||||
if (!graphitiEnabled) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: false,
|
||||
available: false,
|
||||
reason: 'GRAPHITI_ENABLED not set to true'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasOpenAI) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reason: 'OPENAI_API_KEY not set (required for embeddings)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
available: true,
|
||||
host: graphitiHost,
|
||||
port: graphitiPort,
|
||||
database: graphitiDatabase
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_SEARCH_MEMORIES,
|
||||
async (_, projectId: string, query: string): Promise<IPCResult<ContextSearchResult[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// For now, do simple text search in file-based memories
|
||||
// Graphiti search would require running Python subprocess
|
||||
const results: ContextSearchResult[] = [];
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
if (existsSync(specsDir)) {
|
||||
const allSpecDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
});
|
||||
|
||||
for (const specDir of allSpecDirs) {
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
const memoryFiles = readdirSync(memoryDir)
|
||||
.filter((f: string) => f.endsWith('.json'));
|
||||
|
||||
for (const memFile of memoryFiles) {
|
||||
try {
|
||||
const memPath = path.join(memoryDir, memFile);
|
||||
const memContent = readFileSync(memPath, 'utf-8');
|
||||
|
||||
if (memContent.toLowerCase().includes(queryLower)) {
|
||||
const memData = JSON.parse(memContent);
|
||||
results.push({
|
||||
content: JSON.stringify(memData.insights || memData, null, 2),
|
||||
score: 1.0,
|
||||
type: 'session_insight'
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: results.slice(0, 20) };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_GET_MEMORIES,
|
||||
async (_, projectId: string, limit: number = 20): Promise<IPCResult<MemoryEpisode[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const memories: MemoryEpisode[] = [];
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
|
||||
if (existsSync(specsDir)) {
|
||||
const sortedSpecDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
})
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const specDir of sortedSpecDirs) {
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
const memoryFiles = readdirSync(memoryDir)
|
||||
.filter((f: string) => f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const memFile of memoryFiles) {
|
||||
try {
|
||||
const memPath = path.join(memoryDir, memFile);
|
||||
const memContent = readFileSync(memPath, 'utf-8');
|
||||
const memData = JSON.parse(memContent);
|
||||
|
||||
memories.push({
|
||||
id: `${specDir}-${memFile}`,
|
||||
type: memData.type || 'session_insight',
|
||||
timestamp: memData.timestamp || new Date().toISOString(),
|
||||
content: JSON.stringify(memData.insights || memData, null, 2),
|
||||
session_number: memData.session_number
|
||||
});
|
||||
|
||||
if (memories.length >= limit) {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (memories.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: memories };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
// ============================================
|
||||
// Context Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_GET,
|
||||
async (_, projectId: string): Promise<IPCResult<ProjectContextData>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Load project index
|
||||
let projectIndex: ProjectIndex | null = null;
|
||||
const indexPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX);
|
||||
if (existsSync(indexPath)) {
|
||||
const content = readFileSync(indexPath, 'utf-8');
|
||||
projectIndex = JSON.parse(content);
|
||||
}
|
||||
|
||||
// Load graphiti state from most recent spec or project root
|
||||
let memoryState: GraphitiMemoryState | null = null;
|
||||
let memoryStatus: GraphitiMemoryStatus = {
|
||||
enabled: false,
|
||||
available: false,
|
||||
reason: 'Graphiti not configured'
|
||||
};
|
||||
|
||||
// Check for graphiti state in specs
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
if (existsSync(specsDir)) {
|
||||
const specDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
})
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const specDir of specDirs) {
|
||||
const statePath = path.join(specsDir, specDir, AUTO_BUILD_PATHS.GRAPHITI_STATE);
|
||||
if (existsSync(statePath)) {
|
||||
const stateContent = readFileSync(statePath, 'utf-8');
|
||||
memoryState = JSON.parse(stateContent);
|
||||
|
||||
// If we found a state, update memory status
|
||||
if (memoryState?.initialized) {
|
||||
memoryStatus = {
|
||||
enabled: true,
|
||||
available: true,
|
||||
database: memoryState.database || 'auto_build_memory',
|
||||
host: process.env.GRAPHITI_FALKORDB_HOST || 'localhost',
|
||||
port: parseInt(process.env.GRAPHITI_FALKORDB_PORT || '6380', 10)
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check environment for Graphiti config if not found in specs
|
||||
if (!memoryState) {
|
||||
// Load project .env file and global settings to check for Graphiti config
|
||||
let projectEnvVars: Record<string, string> = {};
|
||||
if (project.autoBuildPath) {
|
||||
const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
if (existsSync(projectEnvPath)) {
|
||||
try {
|
||||
const envContent = readFileSync(projectEnvPath, 'utf-8');
|
||||
// Parse .env file inline - handle both Unix and Windows line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
projectEnvVars[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue with empty vars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load global settings for OpenAI API key fallback
|
||||
let globalOpenAIKey: string | undefined;
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const settingsContent = readFileSync(settingsPath, 'utf-8');
|
||||
const globalSettings = JSON.parse(settingsContent);
|
||||
globalOpenAIKey = globalSettings.globalOpenAIApiKey;
|
||||
} catch {
|
||||
// Continue without global settings
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Graphiti config: project .env > process.env
|
||||
const graphitiEnabled =
|
||||
projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' ||
|
||||
process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true';
|
||||
|
||||
// Check for OpenAI key: project .env > global settings > process.env
|
||||
const hasOpenAI =
|
||||
!!projectEnvVars['OPENAI_API_KEY'] ||
|
||||
!!globalOpenAIKey ||
|
||||
!!process.env.OPENAI_API_KEY;
|
||||
|
||||
// Get Graphiti connection details from project .env or process.env
|
||||
const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost';
|
||||
const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10);
|
||||
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory';
|
||||
|
||||
if (graphitiEnabled && hasOpenAI) {
|
||||
memoryStatus = {
|
||||
enabled: true,
|
||||
available: true,
|
||||
host: graphitiHost,
|
||||
port: graphitiPort,
|
||||
database: graphitiDatabase
|
||||
};
|
||||
} else if (graphitiEnabled && !hasOpenAI) {
|
||||
memoryStatus = {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reason: 'OPENAI_API_KEY not set (required for Graphiti embeddings)'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent memories from file-based memory (session insights)
|
||||
const recentMemories: MemoryEpisode[] = [];
|
||||
if (existsSync(specsDir)) {
|
||||
const recentSpecDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
})
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 10); // Last 10 specs
|
||||
|
||||
for (const specDir of recentSpecDirs) {
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
// Load session insights from session_insights subdirectory
|
||||
const sessionInsightsDir = path.join(memoryDir, 'session_insights');
|
||||
if (existsSync(sessionInsightsDir)) {
|
||||
const sessionFiles = readdirSync(sessionInsightsDir)
|
||||
.filter((f: string) => f.startsWith('session_') && f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const sessionFile of sessionFiles.slice(0, 3)) {
|
||||
try {
|
||||
const sessionPath = path.join(sessionInsightsDir, sessionFile);
|
||||
const sessionContent = readFileSync(sessionPath, 'utf-8');
|
||||
const sessionData = JSON.parse(sessionContent);
|
||||
|
||||
// Session files have: session_number, timestamp, subtasks_completed,
|
||||
// discoveries, what_worked, what_failed, recommendations_for_next_session
|
||||
if (sessionData.session_number !== undefined) {
|
||||
recentMemories.push({
|
||||
id: `${specDir}-${sessionFile}`,
|
||||
type: 'session_insight',
|
||||
timestamp: sessionData.timestamp || new Date().toISOString(),
|
||||
content: JSON.stringify({
|
||||
discoveries: sessionData.discoveries,
|
||||
what_worked: sessionData.what_worked,
|
||||
what_failed: sessionData.what_failed,
|
||||
recommendations: sessionData.recommendations_for_next_session,
|
||||
subtasks_completed: sessionData.subtasks_completed
|
||||
}, null, 2),
|
||||
session_number: sessionData.session_number
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also load codebase_map.json as a memory item
|
||||
const codebaseMapPath = path.join(memoryDir, 'codebase_map.json');
|
||||
if (existsSync(codebaseMapPath)) {
|
||||
try {
|
||||
const mapContent = readFileSync(codebaseMapPath, 'utf-8');
|
||||
const mapData = JSON.parse(mapContent);
|
||||
if (mapData.discovered_files && Object.keys(mapData.discovered_files).length > 0) {
|
||||
recentMemories.push({
|
||||
id: `${specDir}-codebase_map`,
|
||||
type: 'codebase_map',
|
||||
timestamp: mapData.last_updated || new Date().toISOString(),
|
||||
content: JSON.stringify(mapData.discovered_files, null, 2),
|
||||
session_number: undefined
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
projectIndex,
|
||||
memoryStatus,
|
||||
memoryState,
|
||||
recentMemories: recentMemories.slice(0, 20),
|
||||
isLoading: false
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load project context'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_REFRESH_INDEX,
|
||||
async (_, projectId: string): Promise<IPCResult<ProjectIndex>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Run the analyzer script to regenerate project_index.json
|
||||
const autoBuildSource = getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Auto-build source path not configured'
|
||||
};
|
||||
}
|
||||
|
||||
const analyzerPath = path.join(autoBuildSource, 'analyzer.py');
|
||||
const indexOutputPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX);
|
||||
|
||||
// Run analyzer
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn('python', [
|
||||
analyzerPath,
|
||||
'--project-dir', project.path,
|
||||
'--output', indexOutputPath
|
||||
], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env }
|
||||
});
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Analyzer exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
|
||||
// Read the new index
|
||||
if (existsSync(indexOutputPath)) {
|
||||
const content = readFileSync(indexOutputPath, 'utf-8');
|
||||
const projectIndex = JSON.parse(content);
|
||||
return { success: true, data: projectIndex };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to generate project index' };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to refresh project index'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_MEMORY_STATUS,
|
||||
async (_, projectId: string): Promise<IPCResult<GraphitiMemoryStatus>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// Load project .env file to check for Graphiti config
|
||||
let projectEnvVars: Record<string, string> = {};
|
||||
if (project.autoBuildPath) {
|
||||
const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env');
|
||||
if (existsSync(projectEnvPath)) {
|
||||
try {
|
||||
const envContent = readFileSync(projectEnvPath, 'utf-8');
|
||||
// Parse .env file inline - handle both Unix and Windows line endings
|
||||
for (const line of envContent.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
projectEnvVars[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue with empty vars
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load global settings for OpenAI API key fallback
|
||||
let globalOpenAIKey: string | undefined;
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const settingsContent = readFileSync(settingsPath, 'utf-8');
|
||||
const globalSettings = JSON.parse(settingsContent);
|
||||
globalOpenAIKey = globalSettings.globalOpenAIApiKey;
|
||||
} catch {
|
||||
// Continue without global settings
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Graphiti config: project .env > process.env
|
||||
const graphitiEnabled =
|
||||
projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' ||
|
||||
process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true';
|
||||
|
||||
// Check for OpenAI key: project .env > global settings > process.env
|
||||
const hasOpenAI =
|
||||
!!projectEnvVars['OPENAI_API_KEY'] ||
|
||||
!!globalOpenAIKey ||
|
||||
!!process.env.OPENAI_API_KEY;
|
||||
|
||||
// Get Graphiti connection details from project .env or process.env
|
||||
const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost';
|
||||
const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10);
|
||||
const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory';
|
||||
|
||||
if (!graphitiEnabled) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: false,
|
||||
available: false,
|
||||
reason: 'GRAPHITI_ENABLED not set to true'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasOpenAI) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
available: false,
|
||||
reason: 'OPENAI_API_KEY not set (required for embeddings)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
available: true,
|
||||
host: graphitiHost,
|
||||
port: graphitiPort,
|
||||
database: graphitiDatabase
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_SEARCH_MEMORIES,
|
||||
async (_, projectId: string, query: string): Promise<IPCResult<ContextSearchResult[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// For now, do simple text search in file-based memories
|
||||
// Graphiti search would require running Python subprocess
|
||||
const results: ContextSearchResult[] = [];
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
if (existsSync(specsDir)) {
|
||||
const allSpecDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
});
|
||||
|
||||
for (const specDir of allSpecDirs) {
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
const memoryFiles = readdirSync(memoryDir)
|
||||
.filter((f: string) => f.endsWith('.json'));
|
||||
|
||||
for (const memFile of memoryFiles) {
|
||||
try {
|
||||
const memPath = path.join(memoryDir, memFile);
|
||||
const memContent = readFileSync(memPath, 'utf-8');
|
||||
|
||||
if (memContent.toLowerCase().includes(queryLower)) {
|
||||
const memData = JSON.parse(memContent);
|
||||
results.push({
|
||||
content: JSON.stringify(memData.insights || memData, null, 2),
|
||||
score: 1.0,
|
||||
type: 'session_insight'
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: results.slice(0, 20) };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CONTEXT_GET_MEMORIES,
|
||||
async (_, projectId: string, limit: number = 20): Promise<IPCResult<MemoryEpisode[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const memories: MemoryEpisode[] = [];
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
|
||||
if (existsSync(specsDir)) {
|
||||
const sortedSpecDirs = readdirSync(specsDir)
|
||||
.filter((f: string) => {
|
||||
const specPath = path.join(specsDir, f);
|
||||
return statSync(specPath).isDirectory();
|
||||
})
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const specDir of sortedSpecDirs) {
|
||||
const memoryDir = path.join(specsDir, specDir, 'memory');
|
||||
if (existsSync(memoryDir)) {
|
||||
const memoryFiles = readdirSync(memoryDir)
|
||||
.filter((f: string) => f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (const memFile of memoryFiles) {
|
||||
try {
|
||||
const memPath = path.join(memoryDir, memFile);
|
||||
const memContent = readFileSync(memPath, 'utf-8');
|
||||
const memData = JSON.parse(memContent);
|
||||
|
||||
memories.push({
|
||||
id: `${specDir}-${memFile}`,
|
||||
type: memData.type || 'session_insight',
|
||||
timestamp: memData.timestamp || new Date().toISOString(),
|
||||
content: JSON.stringify(memData.insights || memData, null, 2),
|
||||
session_number: memData.session_number
|
||||
});
|
||||
|
||||
if (memories.length >= limit) {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (memories.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: memories };
|
||||
}
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
// ============================================
|
||||
// Roadmap Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_GET,
|
||||
async (_, projectId: string): Promise<IPCResult<Roadmap | null>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.ROADMAP_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(roadmapPath)) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(roadmapPath, 'utf-8');
|
||||
const rawRoadmap = JSON.parse(content);
|
||||
|
||||
// Transform snake_case to camelCase for frontend
|
||||
const roadmap: Roadmap = {
|
||||
id: rawRoadmap.id || `roadmap-${Date.now()}`,
|
||||
projectId,
|
||||
projectName: rawRoadmap.project_name || project.name,
|
||||
version: rawRoadmap.version || '1.0',
|
||||
vision: rawRoadmap.vision || '',
|
||||
targetAudience: {
|
||||
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',
|
||||
features: phase.features || [],
|
||||
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
|
||||
}))
|
||||
})),
|
||||
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',
|
||||
phaseId: feature.phase_id,
|
||||
dependencies: feature.dependencies || [],
|
||||
status: feature.status || 'idea',
|
||||
acceptanceCriteria: feature.acceptance_criteria || [],
|
||||
userStories: feature.user_stories || [],
|
||||
linkedSpecId: feature.linked_spec_id
|
||||
})),
|
||||
status: rawRoadmap.status || 'draft',
|
||||
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'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_GENERATE,
|
||||
(_, projectId: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_ERROR,
|
||||
projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start roadmap generation via agent manager
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, false);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'analyzing',
|
||||
progress: 10,
|
||||
message: 'Analyzing project structure...'
|
||||
} as RoadmapGenerationStatus
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_REFRESH,
|
||||
(_, projectId: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_ERROR,
|
||||
projectId,
|
||||
'Project not found'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start roadmap regeneration with refresh flag
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, true);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_PROGRESS,
|
||||
projectId,
|
||||
{
|
||||
phase: 'analyzing',
|
||||
progress: 10,
|
||||
message: 'Refreshing roadmap...'
|
||||
} as RoadmapGenerationStatus
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_UPDATE_FEATURE,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
status: RoadmapFeatureStatus
|
||||
): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.ROADMAP_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(roadmapPath)) {
|
||||
return { success: false, error: 'Roadmap not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
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' };
|
||||
}
|
||||
|
||||
feature.status = status;
|
||||
roadmap.metadata = roadmap.metadata || {};
|
||||
roadmap.metadata.updated_at = new Date().toISOString();
|
||||
|
||||
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update feature'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC,
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
featureId: string
|
||||
): Promise<IPCResult<Task>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const roadmapPath = path.join(
|
||||
project.path,
|
||||
AUTO_BUILD_PATHS.ROADMAP_DIR,
|
||||
AUTO_BUILD_PATHS.ROADMAP_FILE
|
||||
);
|
||||
|
||||
if (!existsSync(roadmapPath)) {
|
||||
return { success: false, error: 'Roadmap not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
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' };
|
||||
}
|
||||
|
||||
// Build task description from feature
|
||||
const taskDescription = `# ${feature.title}
|
||||
|
||||
${feature.description}
|
||||
|
||||
## Rationale
|
||||
${feature.rationale || 'N/A'}
|
||||
|
||||
## User Stories
|
||||
${(feature.user_stories || []).map((s: string) => `- ${s}`).join('\n') || 'N/A'}
|
||||
|
||||
## Acceptance Criteria
|
||||
${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n') || 'N/A'}
|
||||
`;
|
||||
|
||||
// Generate proper spec directory (like task creation)
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
|
||||
// Ensure specs directory exists
|
||||
if (!existsSync(specsDir)) {
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Find next available spec number
|
||||
let specNumber = 1;
|
||||
const existingDirs = existsSync(specsDir)
|
||||
? readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
: [];
|
||||
const existingNumbers = existingDirs
|
||||
.map(name => {
|
||||
const match = name.match(/^(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
})
|
||||
.filter(n => n > 0);
|
||||
if (existingNumbers.length > 0) {
|
||||
specNumber = Math.max(...existingNumbers) + 1;
|
||||
}
|
||||
|
||||
// Create spec ID with zero-padded number and slugified title
|
||||
const slugifiedTitle = feature.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`;
|
||||
|
||||
// Create spec directory
|
||||
const specDir = path.join(specsDir, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Create initial implementation_plan.json
|
||||
const now = new Date().toISOString();
|
||||
const implementationPlan = {
|
||||
feature: feature.title,
|
||||
description: taskDescription,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
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'
|
||||
};
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2));
|
||||
|
||||
// Build metadata
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'roadmap',
|
||||
featureId: feature.id,
|
||||
category: 'feature'
|
||||
};
|
||||
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2));
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata);
|
||||
|
||||
// Update feature with linked spec
|
||||
feature.status = 'planned';
|
||||
feature.linked_spec_id = specId;
|
||||
roadmap.metadata = roadmap.metadata || {};
|
||||
roadmap.metadata.updated_at = new Date().toISOString();
|
||||
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
|
||||
|
||||
// Create task object
|
||||
const task: Task = {
|
||||
id: specId,
|
||||
specId: specId,
|
||||
projectId,
|
||||
title: feature.title,
|
||||
description: taskDescription,
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
logs: [],
|
||||
metadata,
|
||||
createdAt: 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 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-complete', (projectId: string, roadmap: Roadmap) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(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);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,487 @@
|
||||
// ============================================
|
||||
// Terminal Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_CREATE,
|
||||
async (_, options: TerminalCreateOptions): Promise<IPCResult> => {
|
||||
return terminalManager.create(options);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_DESTROY,
|
||||
async (_, id: string): Promise<IPCResult> => {
|
||||
return terminalManager.destroy(id);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_INPUT,
|
||||
(_, id: string, data: string) => {
|
||||
terminalManager.write(id, data);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_RESIZE,
|
||||
(_, id: string, cols: number, rows: number) => {
|
||||
terminalManager.resize(id, cols, rows);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE,
|
||||
(_, id: string, cwd?: string) => {
|
||||
terminalManager.invokeClaude(id, cwd);
|
||||
}
|
||||
);
|
||||
|
||||
// Claude profile management (multi-account support)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILES_GET,
|
||||
async (): Promise<IPCResult<ClaudeProfileSettings>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const settings = profileManager.getSettings();
|
||||
return { success: true, data: settings };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get Claude profiles'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SAVE,
|
||||
async (_, profile: ClaudeProfile): Promise<IPCResult<ClaudeProfile>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
|
||||
// If this is a new profile without an ID, generate one
|
||||
if (!profile.id) {
|
||||
profile.id = profileManager.generateProfileId(profile.name);
|
||||
}
|
||||
|
||||
// Ensure config directory exists for non-default profiles
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
const { mkdirSync, existsSync } = await import('fs');
|
||||
if (!existsSync(profile.configDir)) {
|
||||
mkdirSync(profile.configDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const savedProfile = profileManager.saveProfile(profile);
|
||||
return { success: true, data: savedProfile };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_DELETE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.deleteProfile(profileId);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Cannot delete default or last profile' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_RENAME,
|
||||
async (_, profileId: string, newName: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.renameProfile(profileId, newName);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Profile not found or invalid name' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to rename Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setActiveProfile(profileId);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SWITCH,
|
||||
async (_, terminalId: string, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const result = await terminalManager.switchClaudeProfile(terminalId, profileId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to switch Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = profileManager.getProfile(profileId);
|
||||
if (!profile) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
// Ensure the config directory exists for non-default profiles
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
const { mkdirSync, existsSync } = await import('fs');
|
||||
if (!existsSync(profile.configDir)) {
|
||||
mkdirSync(profile.configDir, { recursive: true });
|
||||
console.log('[IPC] Created config directory:', profile.configDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a terminal and run claude setup-token there
|
||||
// This is needed because claude setup-token requires TTY/raw mode
|
||||
const terminalId = `claude-login-${profileId}-${Date.now()}`;
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||||
|
||||
console.log('[IPC] Initializing Claude profile:', {
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
configDir: profile.configDir,
|
||||
isDefault: profile.isDefault
|
||||
});
|
||||
|
||||
// Create a new terminal for the login process
|
||||
await terminalManager.create({ id: terminalId, cwd: homeDir });
|
||||
|
||||
// Wait a moment for the terminal to initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Build the login command with the profile's config dir
|
||||
// Use export to ensure the variable persists, then run setup-token
|
||||
let loginCommand: string;
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
// Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR="${profile.configDir}" && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
} else {
|
||||
loginCommand = 'claude setup-token';
|
||||
}
|
||||
|
||||
console.log('[IPC] Sending login command to terminal:', loginCommand);
|
||||
|
||||
// Write the login command to the terminal
|
||||
terminalManager.write(terminalId, `${loginCommand}\r`);
|
||||
|
||||
// Notify the renderer that a login terminal was created
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('claude-profile-login-terminal', {
|
||||
terminalId,
|
||||
profileId,
|
||||
profileName: profile.name
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
terminalId,
|
||||
message: `A terminal has been opened to authenticate "${profile.name}". Complete the OAuth flow in your browser, then copy the token shown in the terminal.`
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to initialize Claude profile:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to initialize Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Set OAuth token for a profile (used when capturing from terminal or manual input)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN,
|
||||
async (_, profileId: string, token: string, email?: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setProfileToken(profileId, token, email);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to set OAuth token:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set OAuth token'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get auto-switch settings
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS,
|
||||
async (): Promise<IPCResult<import('../shared/types').ClaudeAutoSwitchSettings>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const settings = profileManager.getAutoSwitchSettings();
|
||||
return { success: true, data: settings };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get auto-switch settings'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update auto-switch settings
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH,
|
||||
async (_, settings: Partial<import('../shared/types').ClaudeAutoSwitchSettings>): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
profileManager.updateAutoSwitchSettings(settings);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update auto-switch settings'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch usage by sending /usage command to terminal
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE,
|
||||
async (_, terminalId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
// Send /usage command to the terminal
|
||||
terminalManager.write(terminalId, '/usage\r');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch usage'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get best available profile
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_GET_BEST_PROFILE,
|
||||
async (_, excludeProfileId?: string): Promise<IPCResult<ClaudeProfile | null>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const bestProfile = profileManager.getBestAvailableProfile(excludeProfileId);
|
||||
return { success: true, data: bestProfile };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get best profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Retry rate-limited operation with a different profile
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE,
|
||||
async (_, request: import('../shared/types').RetryWithProfileRequest): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
|
||||
// Set the new active profile
|
||||
profileManager.setActiveProfile(request.profileId);
|
||||
|
||||
// Get the project
|
||||
const project = projectStore.getProject(request.projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// Retry based on the source
|
||||
switch (request.source) {
|
||||
case 'changelog':
|
||||
// The changelog UI will handle retrying by re-submitting the form
|
||||
// We just need to confirm the profile switch was successful
|
||||
return { success: true };
|
||||
|
||||
case 'task':
|
||||
// For tasks, we would need to restart the task
|
||||
// This is complex and would need task state restoration
|
||||
return { success: true, data: { message: 'Please restart the task manually' } };
|
||||
|
||||
case 'roadmap':
|
||||
// For roadmap, the UI can trigger a refresh
|
||||
return { success: true };
|
||||
|
||||
case 'ideation':
|
||||
// For ideation, the UI can trigger a refresh
|
||||
return { success: true };
|
||||
|
||||
default:
|
||||
return { success: true };
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to retry with profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Terminal session management (persistence/restore)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GET_SESSIONS,
|
||||
async (_, projectPath: string): Promise<IPCResult<import('../shared/types').TerminalSession[]>> => {
|
||||
try {
|
||||
const sessions = terminalManager.getSavedSessions(projectPath);
|
||||
return { success: true, data: sessions };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get terminal sessions'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_RESTORE_SESSION,
|
||||
async (_, session: import('../shared/types').TerminalSession, cols?: number, rows?: number): Promise<IPCResult<import('../shared/types').TerminalRestoreResult>> => {
|
||||
try {
|
||||
const result = await terminalManager.restore(session, cols, rows);
|
||||
return {
|
||||
success: result.success,
|
||||
data: {
|
||||
success: result.success,
|
||||
terminalId: session.id,
|
||||
outputBuffer: result.outputBuffer,
|
||||
error: result.error
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to restore terminal session'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS,
|
||||
async (_, projectPath: string): Promise<IPCResult> => {
|
||||
try {
|
||||
terminalManager.clearSavedSessions(projectPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to clear terminal sessions'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_RESUME_CLAUDE,
|
||||
(_, id: string, sessionId?: string) => {
|
||||
terminalManager.resumeClaude(id, sessionId);
|
||||
}
|
||||
);
|
||||
|
||||
// Get available session dates for a project
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GET_SESSION_DATES,
|
||||
async (_, projectPath?: string) => {
|
||||
try {
|
||||
const dates = terminalManager.getAvailableSessionDates(projectPath);
|
||||
return { success: true, data: dates };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get session dates'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get sessions for a specific date and project
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GET_SESSIONS_FOR_DATE,
|
||||
async (_, date: string, projectPath: string) => {
|
||||
try {
|
||||
const sessions = terminalManager.getSessionsForDate(date, projectPath);
|
||||
return { success: true, data: sessions };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get sessions for date'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Restore all sessions from a specific date
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_RESTORE_FROM_DATE,
|
||||
async (_, date: string, projectPath: string, cols?: number, rows?: number) => {
|
||||
try {
|
||||
const result = await terminalManager.restoreSessionsFromDate(
|
||||
date,
|
||||
projectPath,
|
||||
cols || 80,
|
||||
rows || 24
|
||||
);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to restore sessions from date'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,487 @@
|
||||
// ============================================
|
||||
// Terminal Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_CREATE,
|
||||
async (_, options: TerminalCreateOptions): Promise<IPCResult> => {
|
||||
return terminalManager.create(options);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_DESTROY,
|
||||
async (_, id: string): Promise<IPCResult> => {
|
||||
return terminalManager.destroy(id);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_INPUT,
|
||||
(_, id: string, data: string) => {
|
||||
terminalManager.write(id, data);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_RESIZE,
|
||||
(_, id: string, cols: number, rows: number) => {
|
||||
terminalManager.resize(id, cols, rows);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE,
|
||||
(_, id: string, cwd?: string) => {
|
||||
terminalManager.invokeClaude(id, cwd);
|
||||
}
|
||||
);
|
||||
|
||||
// Claude profile management (multi-account support)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILES_GET,
|
||||
async (): Promise<IPCResult<ClaudeProfileSettings>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const settings = profileManager.getSettings();
|
||||
return { success: true, data: settings };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get Claude profiles'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SAVE,
|
||||
async (_, profile: ClaudeProfile): Promise<IPCResult<ClaudeProfile>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
|
||||
// If this is a new profile without an ID, generate one
|
||||
if (!profile.id) {
|
||||
profile.id = profileManager.generateProfileId(profile.name);
|
||||
}
|
||||
|
||||
// Ensure config directory exists for non-default profiles
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
const { mkdirSync, existsSync } = await import('fs');
|
||||
if (!existsSync(profile.configDir)) {
|
||||
mkdirSync(profile.configDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const savedProfile = profileManager.saveProfile(profile);
|
||||
return { success: true, data: savedProfile };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_DELETE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.deleteProfile(profileId);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Cannot delete default or last profile' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_RENAME,
|
||||
async (_, profileId: string, newName: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.renameProfile(profileId, newName);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Profile not found or invalid name' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to rename Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setActiveProfile(profileId);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SWITCH,
|
||||
async (_, terminalId: string, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const result = await terminalManager.switchClaudeProfile(terminalId, profileId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to switch Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = profileManager.getProfile(profileId);
|
||||
if (!profile) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
// Ensure the config directory exists for non-default profiles
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
const { mkdirSync, existsSync } = await import('fs');
|
||||
if (!existsSync(profile.configDir)) {
|
||||
mkdirSync(profile.configDir, { recursive: true });
|
||||
console.log('[IPC] Created config directory:', profile.configDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a terminal and run claude setup-token there
|
||||
// This is needed because claude setup-token requires TTY/raw mode
|
||||
const terminalId = `claude-login-${profileId}-${Date.now()}`;
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||||
|
||||
console.log('[IPC] Initializing Claude profile:', {
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
configDir: profile.configDir,
|
||||
isDefault: profile.isDefault
|
||||
});
|
||||
|
||||
// Create a new terminal for the login process
|
||||
await terminalManager.create({ id: terminalId, cwd: homeDir });
|
||||
|
||||
// Wait a moment for the terminal to initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Build the login command with the profile's config dir
|
||||
// Use export to ensure the variable persists, then run setup-token
|
||||
let loginCommand: string;
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
// Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR="${profile.configDir}" && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
} else {
|
||||
loginCommand = 'claude setup-token';
|
||||
}
|
||||
|
||||
console.log('[IPC] Sending login command to terminal:', loginCommand);
|
||||
|
||||
// Write the login command to the terminal
|
||||
terminalManager.write(terminalId, `${loginCommand}\r`);
|
||||
|
||||
// Notify the renderer that a login terminal was created
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('claude-profile-login-terminal', {
|
||||
terminalId,
|
||||
profileId,
|
||||
profileName: profile.name
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
terminalId,
|
||||
message: `A terminal has been opened to authenticate "${profile.name}". Complete the OAuth flow in your browser, then copy the token shown in the terminal.`
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to initialize Claude profile:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to initialize Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Set OAuth token for a profile (used when capturing from terminal or manual input)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN,
|
||||
async (_, profileId: string, token: string, email?: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setProfileToken(profileId, token, email);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to set OAuth token:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set OAuth token'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get auto-switch settings
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS,
|
||||
async (): Promise<IPCResult<import('../shared/types').ClaudeAutoSwitchSettings>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const settings = profileManager.getAutoSwitchSettings();
|
||||
return { success: true, data: settings };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get auto-switch settings'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update auto-switch settings
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH,
|
||||
async (_, settings: Partial<import('../shared/types').ClaudeAutoSwitchSettings>): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
profileManager.updateAutoSwitchSettings(settings);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update auto-switch settings'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch usage by sending /usage command to terminal
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE,
|
||||
async (_, terminalId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
// Send /usage command to the terminal
|
||||
terminalManager.write(terminalId, '/usage\r');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch usage'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get best available profile
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_GET_BEST_PROFILE,
|
||||
async (_, excludeProfileId?: string): Promise<IPCResult<ClaudeProfile | null>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const bestProfile = profileManager.getBestAvailableProfile(excludeProfileId);
|
||||
return { success: true, data: bestProfile };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get best profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Retry rate-limited operation with a different profile
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE,
|
||||
async (_, request: import('../shared/types').RetryWithProfileRequest): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
|
||||
// Set the new active profile
|
||||
profileManager.setActiveProfile(request.profileId);
|
||||
|
||||
// Get the project
|
||||
const project = projectStore.getProject(request.projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// Retry based on the source
|
||||
switch (request.source) {
|
||||
case 'changelog':
|
||||
// The changelog UI will handle retrying by re-submitting the form
|
||||
// We just need to confirm the profile switch was successful
|
||||
return { success: true };
|
||||
|
||||
case 'task':
|
||||
// For tasks, we would need to restart the task
|
||||
// This is complex and would need task state restoration
|
||||
return { success: true, data: { message: 'Please restart the task manually' } };
|
||||
|
||||
case 'roadmap':
|
||||
// For roadmap, the UI can trigger a refresh
|
||||
return { success: true };
|
||||
|
||||
case 'ideation':
|
||||
// For ideation, the UI can trigger a refresh
|
||||
return { success: true };
|
||||
|
||||
default:
|
||||
return { success: true };
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to retry with profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Terminal session management (persistence/restore)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GET_SESSIONS,
|
||||
async (_, projectPath: string): Promise<IPCResult<import('../shared/types').TerminalSession[]>> => {
|
||||
try {
|
||||
const sessions = terminalManager.getSavedSessions(projectPath);
|
||||
return { success: true, data: sessions };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get terminal sessions'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_RESTORE_SESSION,
|
||||
async (_, session: import('../shared/types').TerminalSession, cols?: number, rows?: number): Promise<IPCResult<import('../shared/types').TerminalRestoreResult>> => {
|
||||
try {
|
||||
const result = await terminalManager.restore(session, cols, rows);
|
||||
return {
|
||||
success: result.success,
|
||||
data: {
|
||||
success: result.success,
|
||||
terminalId: session.id,
|
||||
outputBuffer: result.outputBuffer,
|
||||
error: result.error
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to restore terminal session'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS,
|
||||
async (_, projectPath: string): Promise<IPCResult> => {
|
||||
try {
|
||||
terminalManager.clearSavedSessions(projectPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to clear terminal sessions'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_RESUME_CLAUDE,
|
||||
(_, id: string, sessionId?: string) => {
|
||||
terminalManager.resumeClaude(id, sessionId);
|
||||
}
|
||||
);
|
||||
|
||||
// Get available session dates for a project
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GET_SESSION_DATES,
|
||||
async (_, projectPath?: string) => {
|
||||
try {
|
||||
const dates = terminalManager.getAvailableSessionDates(projectPath);
|
||||
return { success: true, data: dates };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get session dates'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get sessions for a specific date and project
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GET_SESSIONS_FOR_DATE,
|
||||
async (_, date: string, projectPath: string) => {
|
||||
try {
|
||||
const sessions = terminalManager.getSessionsForDate(date, projectPath);
|
||||
return { success: true, data: sessions };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get sessions for date'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Restore all sessions from a specific date
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_RESTORE_FROM_DATE,
|
||||
async (_, date: string, projectPath: string, cols?: number, rows?: number) => {
|
||||
try {
|
||||
const result = await terminalManager.restoreSessionsFromDate(
|
||||
date,
|
||||
projectPath,
|
||||
cols || 80,
|
||||
rows || 24
|
||||
);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to restore sessions from date'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,221 @@
|
||||
import { ipcMain, dialog, app } from 'electron';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS } from '../../shared/constants';
|
||||
import type {
|
||||
AppSettings,
|
||||
IPCResult
|
||||
} from '../../shared/types';
|
||||
import { AgentManager } from '../agent';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
/**
|
||||
* Auto-detect the auto-claude source path relative to the app location
|
||||
*/
|
||||
const detectAutoBuildSourcePath = (): string | null => {
|
||||
const possiblePaths = [
|
||||
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
|
||||
path.resolve(app.getAppPath(), '..', 'auto-claude'),
|
||||
path.resolve(process.cwd(), 'auto-claude'),
|
||||
path.resolve(__dirname, '..', '..', 'auto-claude')
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register all settings-related IPC handlers
|
||||
*/
|
||||
export function registerSettingsHandlers(
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Settings Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_GET,
|
||||
async (): Promise<IPCResult<AppSettings>> => {
|
||||
let settings = { ...DEFAULT_APP_SETTINGS };
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
settings = { ...settings, ...JSON.parse(content) };
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}
|
||||
|
||||
// If no manual autoBuildPath is set, try to auto-detect
|
||||
if (!settings.autoBuildPath) {
|
||||
const detectedPath = detectAutoBuildSourcePath();
|
||||
if (detectedPath) {
|
||||
settings.autoBuildPath = detectedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: settings as AppSettings };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_SAVE,
|
||||
async (_, settings: Partial<AppSettings>): Promise<IPCResult> => {
|
||||
try {
|
||||
let currentSettings = DEFAULT_APP_SETTINGS;
|
||||
if (existsSync(settingsPath)) {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
currentSettings = { ...currentSettings, ...JSON.parse(content) };
|
||||
}
|
||||
|
||||
const newSettings = { ...currentSettings, ...settings };
|
||||
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2));
|
||||
|
||||
// Apply Python path if changed
|
||||
if (settings.pythonPath || settings.autoBuildPath) {
|
||||
agentManager.configure(settings.pythonPath, settings.autoBuildPath);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save settings'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Dialog Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DIALOG_SELECT_DIRECTORY,
|
||||
async (): Promise<string | null> => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return null;
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory'],
|
||||
title: 'Select Project Directory'
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DIALOG_CREATE_PROJECT_FOLDER,
|
||||
async (
|
||||
_,
|
||||
location: string,
|
||||
name: string,
|
||||
initGit: boolean
|
||||
): Promise<IPCResult<{ path: string; name: string; gitInitialized: boolean }>> => {
|
||||
try {
|
||||
// Validate inputs
|
||||
if (!location || !name) {
|
||||
return { success: false, error: 'Location and name are required' };
|
||||
}
|
||||
|
||||
// Sanitize project name (convert to kebab-case, remove invalid chars)
|
||||
const sanitizedName = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-_]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
if (!sanitizedName) {
|
||||
return { success: false, error: 'Invalid project name' };
|
||||
}
|
||||
|
||||
const projectPath = path.join(location, sanitizedName);
|
||||
|
||||
// Check if folder already exists
|
||||
if (existsSync(projectPath)) {
|
||||
return { success: false, error: `Folder "${sanitizedName}" already exists at this location` };
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
|
||||
// Initialize git if requested
|
||||
let gitInitialized = false;
|
||||
if (initGit) {
|
||||
try {
|
||||
execSync('git init', { cwd: projectPath, stdio: 'ignore' });
|
||||
gitInitialized = true;
|
||||
} catch {
|
||||
// Git init failed, but folder was created - continue without git
|
||||
console.warn('Failed to initialize git repository');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: projectPath,
|
||||
name: sanitizedName,
|
||||
gitInitialized
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create project folder'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DIALOG_GET_DEFAULT_PROJECT_LOCATION,
|
||||
async (): Promise<string | null> => {
|
||||
try {
|
||||
// Return user's home directory + common project folders
|
||||
const homeDir = app.getPath('home');
|
||||
const commonPaths = [
|
||||
path.join(homeDir, 'Projects'),
|
||||
path.join(homeDir, 'Developer'),
|
||||
path.join(homeDir, 'Code'),
|
||||
path.join(homeDir, 'Documents')
|
||||
];
|
||||
|
||||
// Return the first one that exists, or Documents as fallback
|
||||
for (const p of commonPaths) {
|
||||
if (existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(homeDir, 'Documents');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// App Info
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.APP_VERSION, async (): Promise<string> => {
|
||||
return app.getVersion();
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,504 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings } from '../../shared/types';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { TerminalManager } from '../terminal-manager';
|
||||
import { projectStore } from '../project-store';
|
||||
|
||||
|
||||
/**
|
||||
* Register all terminal-related IPC handlers
|
||||
*/
|
||||
export function registerTerminalHandlers(
|
||||
terminalManager: TerminalManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Terminal Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_CREATE,
|
||||
async (_, options: TerminalCreateOptions): Promise<IPCResult> => {
|
||||
return terminalManager.create(options);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_DESTROY,
|
||||
async (_, id: string): Promise<IPCResult> => {
|
||||
return terminalManager.destroy(id);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_INPUT,
|
||||
(_, id: string, data: string) => {
|
||||
terminalManager.write(id, data);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_RESIZE,
|
||||
(_, id: string, cols: number, rows: number) => {
|
||||
terminalManager.resize(id, cols, rows);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE,
|
||||
(_, id: string, cwd?: string) => {
|
||||
terminalManager.invokeClaude(id, cwd);
|
||||
}
|
||||
);
|
||||
|
||||
// Claude profile management (multi-account support)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILES_GET,
|
||||
async (): Promise<IPCResult<ClaudeProfileSettings>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const settings = profileManager.getSettings();
|
||||
return { success: true, data: settings };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get Claude profiles'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SAVE,
|
||||
async (_, profile: ClaudeProfile): Promise<IPCResult<ClaudeProfile>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
|
||||
// If this is a new profile without an ID, generate one
|
||||
if (!profile.id) {
|
||||
profile.id = profileManager.generateProfileId(profile.name);
|
||||
}
|
||||
|
||||
// Ensure config directory exists for non-default profiles
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
const { mkdirSync, existsSync } = await import('fs');
|
||||
if (!existsSync(profile.configDir)) {
|
||||
mkdirSync(profile.configDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const savedProfile = profileManager.saveProfile(profile);
|
||||
return { success: true, data: savedProfile };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_DELETE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.deleteProfile(profileId);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Cannot delete default or last profile' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_RENAME,
|
||||
async (_, profileId: string, newName: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.renameProfile(profileId, newName);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Profile not found or invalid name' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to rename Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setActiveProfile(profileId);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SWITCH,
|
||||
async (_, terminalId: string, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const result = await terminalManager.switchClaudeProfile(terminalId, profileId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to switch Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = profileManager.getProfile(profileId);
|
||||
if (!profile) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
// Ensure the config directory exists for non-default profiles
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
const { mkdirSync, existsSync } = await import('fs');
|
||||
if (!existsSync(profile.configDir)) {
|
||||
mkdirSync(profile.configDir, { recursive: true });
|
||||
console.log('[IPC] Created config directory:', profile.configDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a terminal and run claude setup-token there
|
||||
// This is needed because claude setup-token requires TTY/raw mode
|
||||
const terminalId = `claude-login-${profileId}-${Date.now()}`;
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||||
|
||||
console.log('[IPC] Initializing Claude profile:', {
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
configDir: profile.configDir,
|
||||
isDefault: profile.isDefault
|
||||
});
|
||||
|
||||
// Create a new terminal for the login process
|
||||
await terminalManager.create({ id: terminalId, cwd: homeDir });
|
||||
|
||||
// Wait a moment for the terminal to initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Build the login command with the profile's config dir
|
||||
// Use export to ensure the variable persists, then run setup-token
|
||||
let loginCommand: string;
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
// Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR="${profile.configDir}" && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
} else {
|
||||
loginCommand = 'claude setup-token';
|
||||
}
|
||||
|
||||
console.log('[IPC] Sending login command to terminal:', loginCommand);
|
||||
|
||||
// Write the login command to the terminal
|
||||
terminalManager.write(terminalId, `${loginCommand}\r`);
|
||||
|
||||
// Notify the renderer that a login terminal was created
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('claude-profile-login-terminal', {
|
||||
terminalId,
|
||||
profileId,
|
||||
profileName: profile.name
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
terminalId,
|
||||
message: `A terminal has been opened to authenticate "${profile.name}". Complete the OAuth flow in your browser, then copy the token shown in the terminal.`
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to initialize Claude profile:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to initialize Claude profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Set OAuth token for a profile (used when capturing from terminal or manual input)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN,
|
||||
async (_, profileId: string, token: string, email?: string): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setProfileToken(profileId, token, email);
|
||||
if (!success) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to set OAuth token:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set OAuth token'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get auto-switch settings
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS,
|
||||
async (): Promise<IPCResult<import('../../shared/types').ClaudeAutoSwitchSettings>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const settings = profileManager.getAutoSwitchSettings();
|
||||
return { success: true, data: settings };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get auto-switch settings'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update auto-switch settings
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH,
|
||||
async (_, settings: Partial<import('../../shared/types').ClaudeAutoSwitchSettings>): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
profileManager.updateAutoSwitchSettings(settings);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update auto-switch settings'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch usage by sending /usage command to terminal
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE,
|
||||
async (_, terminalId: string): Promise<IPCResult> => {
|
||||
try {
|
||||
// Send /usage command to the terminal
|
||||
terminalManager.write(terminalId, '/usage\r');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch usage'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get best available profile
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_GET_BEST_PROFILE,
|
||||
async (_, excludeProfileId?: string): Promise<IPCResult<ClaudeProfile | null>> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const bestProfile = profileManager.getBestAvailableProfile(excludeProfileId);
|
||||
return { success: true, data: bestProfile };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get best profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Retry rate-limited operation with a different profile
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE,
|
||||
async (_, request: import('../../shared/types').RetryWithProfileRequest): Promise<IPCResult> => {
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
|
||||
// Set the new active profile
|
||||
profileManager.setActiveProfile(request.profileId);
|
||||
|
||||
// Get the project
|
||||
const project = projectStore.getProject(request.projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
// Retry based on the source
|
||||
switch (request.source) {
|
||||
case 'changelog':
|
||||
// The changelog UI will handle retrying by re-submitting the form
|
||||
// We just need to confirm the profile switch was successful
|
||||
return { success: true };
|
||||
|
||||
case 'task':
|
||||
// For tasks, we would need to restart the task
|
||||
// This is complex and would need task state restoration
|
||||
return { success: true, data: { message: 'Please restart the task manually' } };
|
||||
|
||||
case 'roadmap':
|
||||
// For roadmap, the UI can trigger a refresh
|
||||
return { success: true };
|
||||
|
||||
case 'ideation':
|
||||
// For ideation, the UI can trigger a refresh
|
||||
return { success: true };
|
||||
|
||||
default:
|
||||
return { success: true };
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to retry with profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Terminal session management (persistence/restore)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GET_SESSIONS,
|
||||
async (_, projectPath: string): Promise<IPCResult<import('../../shared/types').TerminalSession[]>> => {
|
||||
try {
|
||||
const sessions = terminalManager.getSavedSessions(projectPath);
|
||||
return { success: true, data: sessions };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get terminal sessions'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_RESTORE_SESSION,
|
||||
async (_, session: import('../../shared/types').TerminalSession, cols?: number, rows?: number): Promise<IPCResult<import('../../shared/types').TerminalRestoreResult>> => {
|
||||
try {
|
||||
const result = await terminalManager.restore(session, cols, rows);
|
||||
return {
|
||||
success: result.success,
|
||||
data: {
|
||||
success: result.success,
|
||||
terminalId: session.id,
|
||||
outputBuffer: result.outputBuffer,
|
||||
error: result.error
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to restore terminal session'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS,
|
||||
async (_, projectPath: string): Promise<IPCResult> => {
|
||||
try {
|
||||
terminalManager.clearSavedSessions(projectPath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to clear terminal sessions'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_RESUME_CLAUDE,
|
||||
(_, id: string, sessionId?: string) => {
|
||||
terminalManager.resumeClaude(id, sessionId);
|
||||
}
|
||||
);
|
||||
|
||||
// Get available session dates for a project
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GET_SESSION_DATES,
|
||||
async (_, projectPath?: string) => {
|
||||
try {
|
||||
const dates = terminalManager.getAvailableSessionDates(projectPath);
|
||||
return { success: true, data: dates };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get session dates'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get sessions for a specific date and project
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_GET_SESSIONS_FOR_DATE,
|
||||
async (_, date: string, projectPath: string) => {
|
||||
try {
|
||||
const sessions = terminalManager.getSessionsForDate(date, projectPath);
|
||||
return { success: true, data: sessions };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get sessions for date'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Restore all sessions from a specific date
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_RESTORE_FROM_DATE,
|
||||
async (_, date: string, projectPath: string, cols?: number, rows?: number) => {
|
||||
try {
|
||||
const result = await terminalManager.restoreSessionsFromDate(
|
||||
date,
|
||||
projectPath,
|
||||
cols || 80,
|
||||
rows || 24
|
||||
);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to restore sessions from date'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared utilities for IPC handlers
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const equalsIndex = trimmed.indexOf('=');
|
||||
if (equalsIndex > 0) {
|
||||
const key = trimmed.substring(0, equalsIndex).trim();
|
||||
let value = trimmed.substring(equalsIndex + 1).trim();
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* IPC Handlers Setup
|
||||
*
|
||||
* This file now serves as a simple entry point that delegates to modularized handlers.
|
||||
* All IPC handlers have been organized into domain-specific modules in ./ipc-handlers/
|
||||
*/
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { AgentManager } from './agent';
|
||||
import { TerminalManager } from './terminal-manager';
|
||||
import { PythonEnvManager } from './python-env-manager';
|
||||
import { setupIpcHandlers as setupModularHandlers } from './ipc-handlers';
|
||||
|
||||
/**
|
||||
* Setup all IPC handlers
|
||||
*
|
||||
* This function has been refactored to use modular handlers for better organization.
|
||||
* The monolithic 6900+ line file has been split into domain-specific modules:
|
||||
*
|
||||
* - project-handlers.ts: Project CRUD and initialization
|
||||
* - task-handlers.ts: Task management and execution
|
||||
* - terminal-handlers.ts: Terminal operations and Claude profiles
|
||||
* - agent-events-handlers.ts: Agent event forwarding
|
||||
* - settings-handlers.ts: App settings and dialogs
|
||||
* - file-handlers.ts: File system operations
|
||||
* - roadmap-handlers.ts: Roadmap generation and management
|
||||
* - context-handlers.ts: Project context and memory
|
||||
* - env-handlers.ts: Environment configuration
|
||||
* - linear-handlers.ts: Linear integration
|
||||
* - github-handlers.ts: GitHub integration
|
||||
* - autobuild-source-handlers.ts: Source updates
|
||||
* - ideation-handlers.ts: Ideation generation
|
||||
* - changelog-handlers.ts: Changelog operations
|
||||
* - insights-handlers.ts: AI insights chat
|
||||
*
|
||||
* @param agentManager - The agent manager instance
|
||||
* @param terminalManager - The terminal manager instance
|
||||
* @param getMainWindow - Function to get the main BrowserWindow
|
||||
* @param pythonEnvManager - The Python environment manager instance
|
||||
*/
|
||||
export function setupIpcHandlers(
|
||||
agentManager: AgentManager,
|
||||
terminalManager: TerminalManager,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
pythonEnvManager: PythonEnvManager
|
||||
): void {
|
||||
// Delegate to modular handler setup
|
||||
setupModularHandlers(agentManager, terminalManager, getMainWindow, pythonEnvManager);
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { app } from 'electron';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason } from '../shared/types';
|
||||
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask } from '../shared/types';
|
||||
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir } from '../shared/constants';
|
||||
import { getAutoBuildPath } from './project-initializer';
|
||||
import { getAutoBuildPath, isInitialized } from './project-initializer';
|
||||
|
||||
interface StoreData {
|
||||
projects: Project[];
|
||||
@@ -126,6 +126,47 @@ export class ProjectStore {
|
||||
return this.data.projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all projects to ensure their .auto-claude folders still exist.
|
||||
* If a project has autoBuildPath set but the folder was deleted,
|
||||
* reset autoBuildPath to empty string so the UI prompts for reinitialization.
|
||||
*
|
||||
* @returns Array of project IDs that were reset due to missing .auto-claude folder
|
||||
*/
|
||||
validateProjects(): string[] {
|
||||
const resetProjectIds: string[] = [];
|
||||
let hasChanges = false;
|
||||
|
||||
for (const project of this.data.projects) {
|
||||
// Skip projects that aren't initialized (autoBuildPath is empty)
|
||||
if (!project.autoBuildPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the project path still exists
|
||||
if (!existsSync(project.path)) {
|
||||
console.log(`[ProjectStore] Project path no longer exists: ${project.path}`);
|
||||
continue; // Don't reset - let user handle this case
|
||||
}
|
||||
|
||||
// Check if .auto-claude folder still exists
|
||||
if (!isInitialized(project.path)) {
|
||||
console.log(`[ProjectStore] .auto-claude folder missing for project "${project.name}" at ${project.path}`);
|
||||
project.autoBuildPath = '';
|
||||
project.updatedAt = new Date();
|
||||
resetProjectIds.push(project.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this.save();
|
||||
console.log(`[ProjectStore] Reset ${resetProjectIds.length} project(s) due to missing .auto-claude folder`);
|
||||
}
|
||||
|
||||
return resetProjectIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a project by ID
|
||||
*/
|
||||
@@ -153,23 +194,35 @@ export class ProjectStore {
|
||||
* Get tasks for a project by scanning specs directory
|
||||
*/
|
||||
getTasks(projectId: string): Task[] {
|
||||
console.log('[ProjectStore] getTasks called with projectId:', projectId);
|
||||
const project = this.getProject(projectId);
|
||||
if (!project) return [];
|
||||
if (!project) {
|
||||
console.log('[ProjectStore] Project not found for id:', projectId);
|
||||
return [];
|
||||
}
|
||||
console.log('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath);
|
||||
|
||||
// Get specs directory path
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
console.log('[ProjectStore] specsDir:', specsDir, 'exists:', existsSync(specsDir));
|
||||
if (!existsSync(specsDir)) return [];
|
||||
|
||||
const tasks: Task[] = [];
|
||||
let specDirs: ReturnType<typeof readdirSync> = [];
|
||||
|
||||
try {
|
||||
const specDirs = readdirSync(specsDir, { withFileTypes: true });
|
||||
specDirs = readdirSync(specsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
console.error('[ProjectStore] Error reading specs directory:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const dir of specDirs) {
|
||||
if (!dir.isDirectory()) continue;
|
||||
if (dir.name === '.gitkeep') continue;
|
||||
for (const dir of specDirs) {
|
||||
if (!dir.isDirectory()) continue;
|
||||
if (dir.name === '.gitkeep') continue;
|
||||
|
||||
try {
|
||||
const specPath = path.join(specsDir, dir.name);
|
||||
const planPath = path.join(specPath, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
const specFilePath = path.join(specPath, AUTO_BUILD_PATHS.SPEC_FILE);
|
||||
@@ -220,16 +273,17 @@ export class ProjectStore {
|
||||
// Determine task status and review reason from plan
|
||||
const { status, reviewReason } = this.determineTaskStatusAndReason(plan, specPath, metadata);
|
||||
|
||||
// Extract subtasks from plan
|
||||
const subtasks = plan?.phases.flatMap((phase) =>
|
||||
phase.subtasks.map((subtask) => ({
|
||||
// Extract subtasks from plan (handle both 'subtasks' and 'chunks' naming)
|
||||
const subtasks = plan?.phases?.flatMap((phase) => {
|
||||
const items = phase.subtasks || (phase as { chunks?: PlanSubtask[] }).chunks || [];
|
||||
return items.map((subtask) => ({
|
||||
id: subtask.id,
|
||||
title: subtask.description,
|
||||
description: subtask.description,
|
||||
status: subtask.status,
|
||||
files: []
|
||||
}))
|
||||
) || [];
|
||||
}));
|
||||
}) || [];
|
||||
|
||||
tasks.push({
|
||||
id: dir.name, // Use spec directory name as ID
|
||||
@@ -245,11 +299,13 @@ export class ProjectStore {
|
||||
createdAt: new Date(plan?.created_at || Date.now()),
|
||||
updatedAt: new Date(plan?.updated_at || Date.now())
|
||||
});
|
||||
} catch (error) {
|
||||
// Log error but continue processing other specs
|
||||
console.error(`[ProjectStore] Error loading spec ${dir.name}:`, error);
|
||||
}
|
||||
} catch {
|
||||
// Return empty array on error
|
||||
}
|
||||
|
||||
console.log('[ProjectStore] Returning', tasks.length, 'tasks out of', specDirs.filter(d => d.isDirectory() && d.name !== '.gitkeep').length, 'spec directories');
|
||||
return tasks;
|
||||
}
|
||||
|
||||
@@ -269,7 +325,8 @@ export class ProjectStore {
|
||||
specPath: string,
|
||||
metadata?: TaskMetadata
|
||||
): { status: TaskStatus; reviewReason?: ReviewReason } {
|
||||
const allSubtasks = plan?.phases?.flatMap((p) => p.subtasks) || [];
|
||||
// Handle both 'subtasks' and 'chunks' naming conventions, filter out undefined
|
||||
const allSubtasks = plan?.phases?.flatMap((p) => p.subtasks || (p as { chunks?: PlanSubtask[] }).chunks || []).filter(Boolean) || [];
|
||||
|
||||
let calculatedStatus: TaskStatus = 'backlog';
|
||||
let reviewReason: ReviewReason | undefined;
|
||||
@@ -327,23 +384,30 @@ export class ProjectStore {
|
||||
if (storedStatus) {
|
||||
// Planning/coding status from the backend should be respected even if subtasks aren't in progress yet
|
||||
// This happens when a task is in planning phase (creating spec) but no subtasks have been started
|
||||
const isActiveProcessStatus = plan.status === 'planning' || plan.status === 'coding';
|
||||
const isActiveProcessStatus = (plan.status as string) === 'planning' || (plan.status as string) === 'coding';
|
||||
|
||||
// Check if this is a plan review (spec approval stage before coding starts)
|
||||
// planStatus: "review" indicates spec creation is complete and awaiting user approval
|
||||
const isPlanReviewStage = (plan as unknown as { planStatus?: string })?.planStatus === 'review';
|
||||
|
||||
const isStoredStatusValid =
|
||||
(storedStatus === calculatedStatus) || // Matches calculated
|
||||
(storedStatus === 'human_review' && calculatedStatus === 'ai_review') || // Human review is more advanced than ai_review
|
||||
(storedStatus === 'human_review' && isPlanReviewStage) || // Plan review stage (awaiting spec approval)
|
||||
(isActiveProcessStatus && storedStatus === 'in_progress'); // Planning/coding phases should show as in_progress
|
||||
|
||||
if (isStoredStatusValid) {
|
||||
// Preserve reviewReason for human_review status
|
||||
if (storedStatus === 'human_review' && !reviewReason) {
|
||||
// Infer reason from subtask states
|
||||
// Infer reason from subtask states or plan review stage
|
||||
const hasFailedSubtasks = allSubtasks.some((s) => s.status === 'failed');
|
||||
const allCompleted = allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed');
|
||||
if (hasFailedSubtasks) {
|
||||
reviewReason = 'errors';
|
||||
} else if (allCompleted) {
|
||||
reviewReason = 'completed';
|
||||
} else if (isPlanReviewStage) {
|
||||
reviewReason = 'plan_review';
|
||||
}
|
||||
}
|
||||
return { status: storedStatus, reviewReason: storedStatus === 'human_review' ? reviewReason : undefined };
|
||||
|
||||
@@ -1,905 +1,14 @@
|
||||
import * as pty from 'node-pty';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../shared/constants';
|
||||
import type { TerminalCreateOptions } from '../shared/types';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getTerminalSessionStore, type TerminalSession } from './terminal-session-store';
|
||||
import { getClaudeProfileManager } from './claude-profile-manager';
|
||||
|
||||
/**
|
||||
* Get the Claude project slug from a project path.
|
||||
* Claude uses the full path with forward slashes replaced by dashes.
|
||||
* Example: /Users/john/project → -Users-john-project
|
||||
* Example: C:\Users\john\project → C--Users-john-project
|
||||
* Terminal Manager Facade
|
||||
* Slim re-export facade for backward compatibility
|
||||
*
|
||||
* The actual implementation has been refactored into modular components:
|
||||
* - terminal/terminal-manager.ts - Main orchestration
|
||||
* - terminal/pty-manager.ts - PTY process management
|
||||
* - terminal/session-handler.ts - Session persistence and restoration
|
||||
* - terminal/output-parser.ts - Output parsing and pattern detection
|
||||
* - terminal/types.ts - TypeScript type definitions
|
||||
*/
|
||||
function getClaudeProjectSlug(projectPath: string): string {
|
||||
// Claude replaces all path separators with dashes (both / and \)
|
||||
return projectPath.replace(/[/\\]/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent Claude session file for a project.
|
||||
* Returns the session ID (filename without .jsonl extension) or null if not found.
|
||||
*/
|
||||
function findMostRecentClaudeSession(projectPath: string): string | null {
|
||||
const slug = getClaudeProjectSlug(projectPath);
|
||||
const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(claudeProjectDir)) {
|
||||
console.log('[TerminalManager] Claude project directory not found:', claudeProjectDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(claudeProjectDir)
|
||||
.filter(f => f.endsWith('.jsonl'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(claudeProjectDir, f),
|
||||
mtime: fs.statSync(path.join(claudeProjectDir, f)).mtime.getTime()
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime); // Most recent first
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('[TerminalManager] No Claude session files found in:', claudeProjectDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return session ID (filename without .jsonl)
|
||||
const sessionId = files[0].name.replace('.jsonl', '');
|
||||
console.log('[TerminalManager] Found most recent Claude session:', sessionId);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
console.error('[TerminalManager] Error finding Claude session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Claude session that was created/modified after a given timestamp.
|
||||
* This helps us find the session that was just started.
|
||||
*/
|
||||
function findClaudeSessionAfter(projectPath: string, afterTimestamp: number): string | null {
|
||||
const slug = getClaudeProjectSlug(projectPath);
|
||||
const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(claudeProjectDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(claudeProjectDir)
|
||||
.filter(f => f.endsWith('.jsonl'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(claudeProjectDir, f),
|
||||
mtime: fs.statSync(path.join(claudeProjectDir, f)).mtime.getTime()
|
||||
}))
|
||||
.filter(f => f.mtime > afterTimestamp)
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return files[0].name.replace('.jsonl', '');
|
||||
} catch (error) {
|
||||
console.error('[TerminalManager] Error finding Claude session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface TerminalProcess {
|
||||
id: string;
|
||||
pty: pty.IPty;
|
||||
isClaudeMode: boolean;
|
||||
projectPath?: string;
|
||||
cwd: string; // Working directory for the terminal
|
||||
claudeSessionId?: string;
|
||||
claudeProfileId?: string; // Which Claude profile is being used (for multi-account support)
|
||||
outputBuffer: string; // Track output for session persistence
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Regex patterns to capture Claude session ID from output
|
||||
// Claude Code outputs something like "Session: abc123" or stores it in the init message
|
||||
const CLAUDE_SESSION_PATTERNS = [
|
||||
// Direct session display (if Claude shows it)
|
||||
/Session(?:\s+ID)?:\s*([a-zA-Z0-9_-]+)/i,
|
||||
// From the JSONL filename pattern that Claude uses internally
|
||||
/session[_-]?id["\s:=]+([a-zA-Z0-9_-]+)/i,
|
||||
// From Claude Code's init output
|
||||
/Resuming session:\s*([a-zA-Z0-9_-]+)/i,
|
||||
// From conversation ID in output
|
||||
/conversation[_-]?id["\s:=]+([a-zA-Z0-9_-]+)/i,
|
||||
];
|
||||
|
||||
// Regex pattern to detect Claude Code rate limit messages
|
||||
// Matches: "Limit reached · resets Dec 17 at 6am (Europe/Oslo)"
|
||||
const RATE_LIMIT_PATTERN = /Limit reached\s*[·•]\s*resets\s+(.+?)$/m;
|
||||
|
||||
// Regex pattern to capture OAuth token from `claude setup-token` output
|
||||
// Token format: sk-ant-oat01-... (varies in length, typically 100+ chars)
|
||||
const OAUTH_TOKEN_PATTERN = /(sk-ant-oat01-[A-Za-z0-9_-]+)/;
|
||||
|
||||
// Pattern to detect email in Claude output (e.g., from /whoami or login success)
|
||||
const EMAIL_PATTERN = /(?:Authenticated as|Logged in as|email[:\s]+)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i;
|
||||
|
||||
export class TerminalManager {
|
||||
private terminals: Map<string, TerminalProcess> = new Map();
|
||||
private getWindow: () => BrowserWindow | null;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
// Track the last notified rate limit reset time per terminal
|
||||
// This prevents duplicate notifications when terminal repaints (e.g., on resize/view switch)
|
||||
private lastNotifiedRateLimitReset: Map<string, string> = new Map();
|
||||
|
||||
constructor(getWindow: () => BrowserWindow | null) {
|
||||
this.getWindow = getWindow;
|
||||
|
||||
// Periodically save session data (every 30 seconds)
|
||||
this.saveTimer = setInterval(() => {
|
||||
this.persistAllSessions();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist all current sessions to disk
|
||||
*/
|
||||
private persistAllSessions(): void {
|
||||
const store = getTerminalSessionStore();
|
||||
|
||||
for (const [, terminal] of this.terminals) {
|
||||
if (terminal.projectPath) {
|
||||
const session: TerminalSession = {
|
||||
id: terminal.id,
|
||||
title: terminal.title,
|
||||
cwd: terminal.cwd, // Use the terminal's working directory
|
||||
projectPath: terminal.projectPath,
|
||||
isClaudeMode: terminal.isClaudeMode,
|
||||
claudeSessionId: terminal.claudeSessionId,
|
||||
outputBuffer: terminal.outputBuffer,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActiveAt: new Date().toISOString()
|
||||
};
|
||||
store.saveSession(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract Claude session ID from output
|
||||
*/
|
||||
private extractClaudeSessionId(data: string): string | null {
|
||||
for (const pattern of CLAUDE_SESSION_PATTERNS) {
|
||||
const match = data.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal process
|
||||
*/
|
||||
async create(options: TerminalCreateOptions & { projectPath?: string }): Promise<{ success: boolean; error?: string }> {
|
||||
const { id, cwd, cols = 80, rows = 24, projectPath } = options;
|
||||
|
||||
console.log('[TerminalManager] Creating terminal:', { id, cwd, cols, rows, projectPath });
|
||||
|
||||
// Check if terminal already exists - return success instead of error
|
||||
// This handles React StrictMode double-render gracefully
|
||||
if (this.terminals.has(id)) {
|
||||
console.log('[TerminalManager] Terminal already exists, returning success:', id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine shell based on platform
|
||||
const shell = process.platform === 'win32'
|
||||
? process.env.COMSPEC || 'cmd.exe'
|
||||
: process.env.SHELL || '/bin/zsh';
|
||||
|
||||
// Get shell args
|
||||
const shellArgs = process.platform === 'win32' ? [] : ['-l'];
|
||||
|
||||
console.log('[TerminalManager] Spawning shell:', shell, shellArgs);
|
||||
|
||||
// Get active Claude profile's environment (OAuth token if available)
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profileEnv = profileManager.getActiveProfileEnv();
|
||||
|
||||
if (profileEnv.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
console.log('[TerminalManager] Injecting OAuth token from active profile into terminal');
|
||||
}
|
||||
|
||||
// Spawn the pty process with profile environment
|
||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd: cwd || os.homedir(),
|
||||
env: {
|
||||
...process.env,
|
||||
...profileEnv, // Include active profile's OAuth token
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[TerminalManager] PTY process spawned, pid:', ptyProcess.pid);
|
||||
|
||||
// Store the terminal with its working directory
|
||||
const terminalCwd = cwd || os.homedir();
|
||||
const terminal: TerminalProcess = {
|
||||
id,
|
||||
pty: ptyProcess,
|
||||
isClaudeMode: false,
|
||||
projectPath,
|
||||
cwd: terminalCwd,
|
||||
outputBuffer: '',
|
||||
title: `Terminal ${this.terminals.size + 1}`
|
||||
};
|
||||
this.terminals.set(id, terminal);
|
||||
|
||||
// Handle data from terminal
|
||||
ptyProcess.onData((data) => {
|
||||
// Append to output buffer (limit to 100KB)
|
||||
terminal.outputBuffer = (terminal.outputBuffer + data).slice(-100000);
|
||||
|
||||
// Try to extract Claude session ID if in Claude mode
|
||||
if (terminal.isClaudeMode && !terminal.claudeSessionId) {
|
||||
const sessionId = this.extractClaudeSessionId(data);
|
||||
if (sessionId) {
|
||||
terminal.claudeSessionId = sessionId;
|
||||
console.log('[TerminalManager] Captured Claude session ID:', sessionId);
|
||||
|
||||
// Save to persistent store
|
||||
if (terminal.projectPath) {
|
||||
const store = getTerminalSessionStore();
|
||||
store.updateClaudeSessionId(terminal.projectPath, id, sessionId);
|
||||
}
|
||||
|
||||
// Notify renderer
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, id, sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for rate limit messages
|
||||
if (terminal.isClaudeMode) {
|
||||
const rateLimitMatch = data.match(RATE_LIMIT_PATTERN);
|
||||
if (rateLimitMatch) {
|
||||
const resetTime = rateLimitMatch[1].trim();
|
||||
const lastNotifiedReset = this.lastNotifiedRateLimitReset.get(id);
|
||||
|
||||
// Only notify if this is a different reset time than we last notified about
|
||||
// This prevents duplicate notifications when terminal repaints (resize, view switch)
|
||||
if (resetTime !== lastNotifiedReset) {
|
||||
this.lastNotifiedRateLimitReset.set(id, resetTime);
|
||||
console.log('[TerminalManager] Rate limit detected, reset:', resetTime);
|
||||
|
||||
// Record rate limit event in profile manager
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const currentProfileId = terminal.claudeProfileId || 'default';
|
||||
try {
|
||||
const rateLimitEvent = profileManager.recordRateLimitEvent(currentProfileId, resetTime);
|
||||
console.log('[TerminalManager] Recorded rate limit event:', rateLimitEvent.type);
|
||||
} catch (err) {
|
||||
console.error('[TerminalManager] Failed to record rate limit event:', err);
|
||||
}
|
||||
|
||||
// Check for auto-switch
|
||||
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
|
||||
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
|
||||
|
||||
// Notify renderer with extended info
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, {
|
||||
terminalId: id,
|
||||
resetTime,
|
||||
detectedAt: new Date().toISOString(),
|
||||
profileId: currentProfileId,
|
||||
suggestedProfileId: bestProfile?.id,
|
||||
suggestedProfileName: bestProfile?.name,
|
||||
autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-switch if enabled and a better profile is available
|
||||
if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestProfile) {
|
||||
console.log('[TerminalManager] Auto-switching to profile:', bestProfile.name);
|
||||
this.switchClaudeProfile(id, bestProfile.id).then(result => {
|
||||
if (result.success) {
|
||||
console.log('[TerminalManager] Auto-switch successful');
|
||||
} else {
|
||||
console.error('[TerminalManager] Auto-switch failed:', result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for OAuth token in terminal output (from `claude setup-token`)
|
||||
// Automatically save to the profile - user never sees the token
|
||||
const tokenMatch = data.match(OAUTH_TOKEN_PATTERN);
|
||||
if (tokenMatch) {
|
||||
const token = tokenMatch[1];
|
||||
console.log('[TerminalManager] OAuth token detected, length:', token.length);
|
||||
|
||||
// Also try to capture email if present in recent output
|
||||
const emailMatch = terminal.outputBuffer.match(EMAIL_PATTERN);
|
||||
const email = emailMatch ? emailMatch[1] : undefined;
|
||||
|
||||
// Extract profile ID from terminal ID (format: claude-login-{profileId}-{timestamp})
|
||||
const profileIdMatch = id.match(/claude-login-(profile-\d+)-/);
|
||||
|
||||
if (profileIdMatch) {
|
||||
const profileId = profileIdMatch[1];
|
||||
|
||||
// Auto-save the token to the profile (encrypted)
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setProfileToken(profileId, token, email);
|
||||
|
||||
if (success) {
|
||||
console.log('[TerminalManager] OAuth token auto-saved to profile:', profileId);
|
||||
|
||||
// Notify frontend that authentication completed (without exposing token)
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: id,
|
||||
profileId,
|
||||
email,
|
||||
success: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error('[TerminalManager] Failed to save OAuth token to profile:', profileId);
|
||||
}
|
||||
} else {
|
||||
console.log('[TerminalManager] OAuth token detected but not in a profile login terminal');
|
||||
// Still notify frontend for manual handling
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: id,
|
||||
email,
|
||||
success: false,
|
||||
message: 'Token detected but no profile associated with this terminal',
|
||||
detectedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OUTPUT, id, data);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle terminal exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log('[TerminalManager] Terminal exited:', id, 'code:', exitCode);
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_EXIT, id, exitCode);
|
||||
}
|
||||
|
||||
// Remove session from persistent store when terminal exits
|
||||
if (terminal.projectPath) {
|
||||
const store = getTerminalSessionStore();
|
||||
store.removeSession(terminal.projectPath, id);
|
||||
}
|
||||
|
||||
// Clean up rate limit tracking
|
||||
this.lastNotifiedRateLimitReset.delete(id);
|
||||
|
||||
this.terminals.delete(id);
|
||||
});
|
||||
|
||||
// Save initial session state
|
||||
if (projectPath) {
|
||||
const store = getTerminalSessionStore();
|
||||
store.saveSession({
|
||||
id,
|
||||
title: terminal.title,
|
||||
cwd: cwd || os.homedir(),
|
||||
projectPath,
|
||||
isClaudeMode: false,
|
||||
outputBuffer: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActiveAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[TerminalManager] Terminal created successfully:', id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[TerminalManager] Error creating terminal:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create terminal',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a terminal session (create PTY and optionally resume Claude)
|
||||
*/
|
||||
async restore(session: TerminalSession, cols = 80, rows = 24): Promise<{ success: boolean; error?: string; outputBuffer?: string }> {
|
||||
console.log('[TerminalManager] Restoring terminal session:', session.id, 'Claude mode:', session.isClaudeMode);
|
||||
|
||||
// First create the base terminal
|
||||
const result = await this.create({
|
||||
id: session.id,
|
||||
cwd: session.cwd,
|
||||
cols,
|
||||
rows,
|
||||
projectPath: session.projectPath
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const terminal = this.terminals.get(session.id);
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found after creation' };
|
||||
}
|
||||
|
||||
// Set the title
|
||||
terminal.title = session.title;
|
||||
|
||||
// If it was a Claude session, try to resume
|
||||
if (session.isClaudeMode) {
|
||||
// Wait for shell to fully initialize (shell frameworks like oh-my-zsh need time)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
terminal.isClaudeMode = true;
|
||||
terminal.claudeSessionId = session.claudeSessionId;
|
||||
|
||||
// Build the resume command - always cd to the project directory first
|
||||
// because Claude sessions are stored per-project in ~/.claude/projects/{project-slug}/
|
||||
const projectDir = session.cwd || session.projectPath;
|
||||
const startTime = Date.now();
|
||||
let resumeCommand: string;
|
||||
|
||||
// Use platform-appropriate clear command
|
||||
const clearCmd = process.platform === 'win32' ? 'cls' : 'clear';
|
||||
|
||||
if (session.claudeSessionId) {
|
||||
// Resume specific session with explicit directory
|
||||
// Clear screen first to avoid mixing old output replay with new session
|
||||
resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume "${session.claudeSessionId}"`;
|
||||
console.log('[TerminalManager] Resuming Claude with session ID:', session.claudeSessionId, 'in', projectDir);
|
||||
} else {
|
||||
// No specific session ID - use --resume to show session picker
|
||||
// This lets user choose which session to resume for this terminal
|
||||
// (Using --continue would resume the same session in all terminals)
|
||||
resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume`;
|
||||
console.log('[TerminalManager] Opening Claude session picker in', projectDir);
|
||||
}
|
||||
|
||||
terminal.pty.write(`${resumeCommand}\r`);
|
||||
|
||||
// Notify renderer about title change
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, session.id, 'Claude');
|
||||
}
|
||||
|
||||
// If we don't have a session ID, try to capture it after user selects from picker
|
||||
if (!session.claudeSessionId && projectDir) {
|
||||
this.captureClaudeSessionId(session.id, projectDir, startTime);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
outputBuffer: session.outputBuffer // Return buffer for replay in UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a terminal process
|
||||
*/
|
||||
async destroy(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove from persistent store
|
||||
if (terminal.projectPath) {
|
||||
const store = getTerminalSessionStore();
|
||||
store.removeSession(terminal.projectPath, id);
|
||||
}
|
||||
|
||||
// Clean up rate limit tracking
|
||||
this.lastNotifiedRateLimitReset.delete(id);
|
||||
|
||||
terminal.pty.kill();
|
||||
this.terminals.delete(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to destroy terminal',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to a terminal
|
||||
*/
|
||||
write(id: string, data: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
terminal.pty.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a terminal
|
||||
*/
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
terminal.pty.resize(cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke Claude in a terminal with optional profile override.
|
||||
* Note: For new terminals, the OAuth token is injected at spawn time (invisible to user).
|
||||
* For profile switches, we use a temp file to avoid exposing the token.
|
||||
*/
|
||||
invokeClaude(id: string, cwd?: string, profileId?: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
terminal.isClaudeMode = true;
|
||||
terminal.claudeSessionId = undefined;
|
||||
|
||||
// Record timestamp before starting Claude
|
||||
const startTime = Date.now();
|
||||
const projectPath = cwd || terminal.projectPath || terminal.cwd;
|
||||
|
||||
// Get the Claude profile to use
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileId
|
||||
? profileManager.getProfile(profileId)
|
||||
: profileManager.getActiveProfile();
|
||||
|
||||
const previousProfileId = terminal.claudeProfileId;
|
||||
terminal.claudeProfileId = activeProfile?.id;
|
||||
|
||||
// Build the command - only inject token if switching profiles mid-session
|
||||
// New terminals already have the token injected at spawn time (invisible)
|
||||
const cwdCommand = cwd ? `cd "${cwd}" && ` : '';
|
||||
|
||||
// Only inject token if explicitly switching profiles (profileId provided and different)
|
||||
const needsEnvOverride = profileId && profileId !== previousProfileId;
|
||||
|
||||
if (needsEnvOverride && activeProfile && !activeProfile.isDefault) {
|
||||
const token = profileManager.getProfileToken(activeProfile.id);
|
||||
|
||||
if (token) {
|
||||
// Use a temp file to inject the token without exposing it in terminal output
|
||||
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}`);
|
||||
fs.writeFileSync(tempFile, `export CLAUDE_CODE_OAUTH_TOKEN="${token}"\n`, { mode: 0o600 });
|
||||
|
||||
// Source the temp file, delete it, then run claude - token never visible
|
||||
terminal.pty.write(`${cwdCommand}source "${tempFile}" && rm -f "${tempFile}" && claude\r`);
|
||||
console.log('[TerminalManager] Switching to Claude profile:', activeProfile.name, '(via secure temp file)');
|
||||
return;
|
||||
} else if (activeProfile.configDir) {
|
||||
// Fallback to config dir for legacy profiles without tokens
|
||||
terminal.pty.write(`${cwdCommand}CLAUDE_CONFIG_DIR="${activeProfile.configDir}" claude\r`);
|
||||
console.log('[TerminalManager] Using Claude profile:', activeProfile.name, 'config:', activeProfile.configDir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeProfile && !activeProfile.isDefault) {
|
||||
console.log('[TerminalManager] Using Claude profile:', activeProfile.name, '(from terminal environment)');
|
||||
}
|
||||
|
||||
// Normal case: token already in terminal environment from spawn time
|
||||
terminal.pty.write(`${cwdCommand}claude\r`);
|
||||
|
||||
// Mark the profile as used
|
||||
if (activeProfile) {
|
||||
profileManager.markProfileUsed(activeProfile.id);
|
||||
}
|
||||
|
||||
// Notify the renderer about title change (include profile name if not default)
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
const title = activeProfile && !activeProfile.isDefault
|
||||
? `Claude (${activeProfile.name})`
|
||||
: 'Claude';
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, id, title);
|
||||
}
|
||||
|
||||
// Update persistent store
|
||||
if (terminal.projectPath) {
|
||||
const store = getTerminalSessionStore();
|
||||
const session = store.getSession(terminal.projectPath, id);
|
||||
if (session) {
|
||||
store.saveSession({
|
||||
...session,
|
||||
isClaudeMode: true,
|
||||
lastActiveAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Capture Claude session ID after a delay (give Claude time to create the session file)
|
||||
if (projectPath) {
|
||||
this.captureClaudeSessionId(id, projectPath, startTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch a terminal to use a different Claude profile.
|
||||
* This will exit the current Claude session and restart with the new profile.
|
||||
*/
|
||||
async switchClaudeProfile(id: string, profileId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = profileManager.getProfile(profileId);
|
||||
if (!profile) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
console.log('[TerminalManager] Switching to Claude profile:', profile.name);
|
||||
|
||||
// If Claude is currently running, exit it first
|
||||
if (terminal.isClaudeMode) {
|
||||
// Send Ctrl+C to interrupt current Claude session
|
||||
terminal.pty.write('\x03');
|
||||
// Wait for Claude to exit
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// Send /exit command in case it didn't fully exit
|
||||
terminal.pty.write('/exit\r');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// Clear rate limit tracking for this terminal (new profile = new limit)
|
||||
this.lastNotifiedRateLimitReset.delete(id);
|
||||
|
||||
// Get the project path for re-invoking
|
||||
const projectPath = terminal.projectPath || terminal.cwd;
|
||||
|
||||
// Re-invoke Claude with the new profile
|
||||
this.invokeClaude(id, projectPath, profileId);
|
||||
|
||||
// Update the active profile globally
|
||||
profileManager.setActiveProfile(profileId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to capture Claude session ID by scanning the session directory.
|
||||
* Polls periodically until a new session is found or timeout.
|
||||
*/
|
||||
private captureClaudeSessionId(terminalId: string, projectPath: string, startTime: number): void {
|
||||
const terminal = this.terminals.get(terminalId);
|
||||
if (!terminal) return;
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10; // Try for up to 10 seconds
|
||||
|
||||
const checkForSession = () => {
|
||||
attempts++;
|
||||
|
||||
// Check if terminal still exists and is in Claude mode
|
||||
const currentTerminal = this.terminals.get(terminalId);
|
||||
if (!currentTerminal || !currentTerminal.isClaudeMode) {
|
||||
return; // Terminal closed or Claude exited
|
||||
}
|
||||
|
||||
// Already have a session ID (maybe captured from output)
|
||||
if (currentTerminal.claudeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Look for a session file created after we started Claude
|
||||
const sessionId = findClaudeSessionAfter(projectPath, startTime);
|
||||
|
||||
if (sessionId) {
|
||||
currentTerminal.claudeSessionId = sessionId;
|
||||
console.log('[TerminalManager] Captured Claude session ID from directory:', sessionId);
|
||||
|
||||
// Save to persistent store
|
||||
if (currentTerminal.projectPath) {
|
||||
const store = getTerminalSessionStore();
|
||||
store.updateClaudeSessionId(currentTerminal.projectPath, terminalId, sessionId);
|
||||
}
|
||||
|
||||
// Notify renderer
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminalId, sessionId);
|
||||
}
|
||||
} else if (attempts < maxAttempts) {
|
||||
// Try again in 1 second
|
||||
setTimeout(checkForSession, 1000);
|
||||
} else {
|
||||
console.log('[TerminalManager] Could not capture Claude session ID after', maxAttempts, 'attempts');
|
||||
}
|
||||
};
|
||||
|
||||
// First check after 2 seconds (give Claude time to start)
|
||||
setTimeout(checkForSession, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume Claude in a terminal with a specific session ID
|
||||
*/
|
||||
resumeClaude(id: string, sessionId?: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
terminal.isClaudeMode = true;
|
||||
|
||||
let command: string;
|
||||
if (sessionId) {
|
||||
command = `claude --resume "${sessionId}"`;
|
||||
terminal.claudeSessionId = sessionId;
|
||||
} else {
|
||||
command = 'claude --continue';
|
||||
}
|
||||
|
||||
terminal.pty.write(`${command}\r`);
|
||||
|
||||
// Notify the renderer about title change
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, id, 'Claude');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved sessions for a project
|
||||
*/
|
||||
getSavedSessions(projectPath: string): TerminalSession[] {
|
||||
const store = getTerminalSessionStore();
|
||||
return store.getSessions(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear saved sessions for a project
|
||||
*/
|
||||
clearSavedSessions(projectPath: string): void {
|
||||
const store = getTerminalSessionStore();
|
||||
store.clearProjectSessions(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available session dates for a project
|
||||
*/
|
||||
getAvailableSessionDates(projectPath?: string): import('./terminal-session-store').SessionDateInfo[] {
|
||||
const store = getTerminalSessionStore();
|
||||
return store.getAvailableDates(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sessions for a specific date and project
|
||||
*/
|
||||
getSessionsForDate(date: string, projectPath: string): TerminalSession[] {
|
||||
const store = getTerminalSessionStore();
|
||||
return store.getSessionsForDate(date, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all sessions from a specific date for a project
|
||||
*/
|
||||
async restoreSessionsFromDate(
|
||||
date: string,
|
||||
projectPath: string,
|
||||
cols = 80,
|
||||
rows = 24
|
||||
): Promise<{ restored: number; failed: number; sessions: Array<{ id: string; success: boolean; error?: string }> }> {
|
||||
const store = getTerminalSessionStore();
|
||||
const sessions = store.getSessionsForDate(date, projectPath);
|
||||
|
||||
const results: Array<{ id: string; success: boolean; error?: string }> = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const result = await this.restore(session, cols, rows);
|
||||
results.push({
|
||||
id: session.id,
|
||||
success: result.success,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
restored: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length,
|
||||
sessions: results
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all terminal processes
|
||||
*/
|
||||
async killAll(): Promise<void> {
|
||||
// Save all sessions before killing
|
||||
this.persistAllSessions();
|
||||
|
||||
// Clear the save timer
|
||||
if (this.saveTimer) {
|
||||
clearInterval(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const [id, terminal] of this.terminals) {
|
||||
promises.push(
|
||||
new Promise((resolve) => {
|
||||
try {
|
||||
terminal.pty.kill();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
this.terminals.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active terminal IDs
|
||||
*/
|
||||
getActiveTerminalIds(): string[] {
|
||||
return Array.from(this.terminals.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a terminal is in Claude mode
|
||||
*/
|
||||
isClaudeMode(id: string): boolean {
|
||||
const terminal = this.terminals.get(id);
|
||||
return terminal?.isClaudeMode ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Claude session ID for a terminal
|
||||
*/
|
||||
getClaudeSessionId(id: string): string | undefined {
|
||||
const terminal = this.terminals.get(id);
|
||||
return terminal?.claudeSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update terminal title
|
||||
*/
|
||||
setTitle(id: string, title: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
terminal.title = title;
|
||||
}
|
||||
}
|
||||
}
|
||||
export { TerminalManager } from './terminal/terminal-manager';
|
||||
export type { TerminalProcess } from './terminal/types';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Terminal Module
|
||||
* Modular terminal management system with Claude integration
|
||||
*/
|
||||
|
||||
// Main manager
|
||||
export { TerminalManager } from './terminal-manager';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
TerminalProcess,
|
||||
RateLimitEvent,
|
||||
OAuthTokenEvent,
|
||||
SessionCaptureResult,
|
||||
TerminalOperationResult,
|
||||
WindowGetter
|
||||
} from './types';
|
||||
|
||||
// Output parsing utilities
|
||||
export * as OutputParser from './output-parser';
|
||||
|
||||
// PTY management utilities
|
||||
export * as PtyManager from './pty-manager';
|
||||
|
||||
// Session management utilities
|
||||
export * as SessionHandler from './session-handler';
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Output Parser Module
|
||||
* Handles parsing and pattern detection in terminal output
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regex patterns to capture Claude session ID from output
|
||||
*/
|
||||
const CLAUDE_SESSION_PATTERNS = [
|
||||
/Session(?:\s+ID)?:\s*([a-zA-Z0-9_-]+)/i,
|
||||
/session[_-]?id["\s:=]+([a-zA-Z0-9_-]+)/i,
|
||||
/Resuming session:\s*([a-zA-Z0-9_-]+)/i,
|
||||
/conversation[_-]?id["\s:=]+([a-zA-Z0-9_-]+)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Regex pattern to detect Claude Code rate limit messages
|
||||
* Matches: "Limit reached · resets Dec 17 at 6am (Europe/Oslo)"
|
||||
*/
|
||||
const RATE_LIMIT_PATTERN = /Limit reached\s*[·•]\s*resets\s+(.+?)$/m;
|
||||
|
||||
/**
|
||||
* Regex pattern to capture OAuth token from `claude setup-token` output
|
||||
*/
|
||||
const OAUTH_TOKEN_PATTERN = /(sk-ant-oat01-[A-Za-z0-9_-]+)/;
|
||||
|
||||
/**
|
||||
* Pattern to detect email in Claude output
|
||||
*/
|
||||
const EMAIL_PATTERN = /(?:Authenticated as|Logged in as|email[:\s]+)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i;
|
||||
|
||||
/**
|
||||
* Extract Claude session ID from output
|
||||
*/
|
||||
export function extractClaudeSessionId(data: string): string | null {
|
||||
for (const pattern of CLAUDE_SESSION_PATTERNS) {
|
||||
const match = data.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract rate limit reset time from output
|
||||
*/
|
||||
export function extractRateLimitReset(data: string): string | null {
|
||||
const match = data.match(RATE_LIMIT_PATTERN);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract OAuth token from output
|
||||
*/
|
||||
export function extractOAuthToken(data: string): string | null {
|
||||
const match = data.match(OAUTH_TOKEN_PATTERN);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract email from output
|
||||
*/
|
||||
export function extractEmail(data: string): string | null {
|
||||
const match = data.match(EMAIL_PATTERN);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output contains a rate limit message
|
||||
*/
|
||||
export function hasRateLimitMessage(data: string): boolean {
|
||||
return RATE_LIMIT_PATTERN.test(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output contains an OAuth token
|
||||
*/
|
||||
export function hasOAuthToken(data: string): boolean {
|
||||
return OAUTH_TOKEN_PATTERN.test(data);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* PTY Manager Module
|
||||
* Handles low-level PTY process creation and lifecycle
|
||||
*/
|
||||
|
||||
import * as pty from 'node-pty';
|
||||
import * as os from 'os';
|
||||
import type { TerminalProcess, WindowGetter, TerminalOperationResult } from './types';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
|
||||
/**
|
||||
* Spawn a new PTY process with appropriate shell and environment
|
||||
*/
|
||||
export function spawnPtyProcess(
|
||||
cwd: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
profileEnv?: Record<string, string>
|
||||
): pty.IPty {
|
||||
const shell = process.platform === 'win32'
|
||||
? process.env.COMSPEC || 'cmd.exe'
|
||||
: process.env.SHELL || '/bin/zsh';
|
||||
|
||||
const shellArgs = process.platform === 'win32' ? [] : ['-l'];
|
||||
|
||||
console.log('[PtyManager] Spawning shell:', shell, shellArgs);
|
||||
|
||||
return pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd: cwd || os.homedir(),
|
||||
env: {
|
||||
...process.env,
|
||||
...profileEnv,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup PTY event handlers for a terminal process
|
||||
*/
|
||||
export function setupPtyHandlers(
|
||||
terminal: TerminalProcess,
|
||||
terminals: Map<string, TerminalProcess>,
|
||||
getWindow: WindowGetter,
|
||||
onDataCallback: (terminal: TerminalProcess, data: string) => void,
|
||||
onExitCallback: (terminal: TerminalProcess) => void
|
||||
): void {
|
||||
const { id, pty: ptyProcess } = terminal;
|
||||
|
||||
// Handle data from terminal
|
||||
ptyProcess.onData((data) => {
|
||||
// Append to output buffer (limit to 100KB)
|
||||
terminal.outputBuffer = (terminal.outputBuffer + data).slice(-100000);
|
||||
|
||||
// Call custom data handler
|
||||
onDataCallback(terminal, data);
|
||||
|
||||
// Send to renderer
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OUTPUT, id, data);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle terminal exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log('[PtyManager] Terminal exited:', id, 'code:', exitCode);
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_EXIT, id, exitCode);
|
||||
}
|
||||
|
||||
// Call custom exit handler
|
||||
onExitCallback(terminal);
|
||||
|
||||
terminals.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write data to a PTY process
|
||||
*/
|
||||
export function writeToPty(terminal: TerminalProcess, data: string): void {
|
||||
terminal.pty.write(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a PTY process
|
||||
*/
|
||||
export function resizePty(terminal: TerminalProcess, cols: number, rows: number): void {
|
||||
terminal.pty.resize(cols, rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a PTY process
|
||||
*/
|
||||
export function killPty(terminal: TerminalProcess): void {
|
||||
terminal.pty.kill();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active Claude profile environment variables
|
||||
*/
|
||||
export function getActiveProfileEnv(): Record<string, string> {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
return profileManager.getActiveProfileEnv();
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Session Handler Module
|
||||
* Manages terminal session persistence, restoration, and Claude session tracking
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { TerminalProcess, WindowGetter, SessionCaptureResult } from './types';
|
||||
import { getTerminalSessionStore, type TerminalSession } from '../terminal-session-store';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
|
||||
/**
|
||||
* Get the Claude project slug from a project path.
|
||||
* Claude uses the full path with forward slashes replaced by dashes.
|
||||
*/
|
||||
function getClaudeProjectSlug(projectPath: string): string {
|
||||
return projectPath.replace(/[/\\]/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent Claude session file for a project
|
||||
*/
|
||||
export function findMostRecentClaudeSession(projectPath: string): string | null {
|
||||
const slug = getClaudeProjectSlug(projectPath);
|
||||
const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(claudeProjectDir)) {
|
||||
console.log('[SessionHandler] Claude project directory not found:', claudeProjectDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(claudeProjectDir)
|
||||
.filter(f => f.endsWith('.jsonl'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(claudeProjectDir, f),
|
||||
mtime: fs.statSync(path.join(claudeProjectDir, f)).mtime.getTime()
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('[SessionHandler] No Claude session files found in:', claudeProjectDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = files[0].name.replace('.jsonl', '');
|
||||
console.log('[SessionHandler] Found most recent Claude session:', sessionId);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
console.error('[SessionHandler] Error finding Claude session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Claude session created/modified after a given timestamp
|
||||
*/
|
||||
export function findClaudeSessionAfter(projectPath: string, afterTimestamp: number): string | null {
|
||||
const slug = getClaudeProjectSlug(projectPath);
|
||||
const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(claudeProjectDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(claudeProjectDir)
|
||||
.filter(f => f.endsWith('.jsonl'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(claudeProjectDir, f),
|
||||
mtime: fs.statSync(path.join(claudeProjectDir, f)).mtime.getTime()
|
||||
}))
|
||||
.filter(f => f.mtime > afterTimestamp)
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return files[0].name.replace('.jsonl', '');
|
||||
} catch (error) {
|
||||
console.error('[SessionHandler] Error finding Claude session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a terminal session to disk
|
||||
*/
|
||||
export function persistSession(terminal: TerminalProcess): void {
|
||||
if (!terminal.projectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = getTerminalSessionStore();
|
||||
const session: TerminalSession = {
|
||||
id: terminal.id,
|
||||
title: terminal.title,
|
||||
cwd: terminal.cwd,
|
||||
projectPath: terminal.projectPath,
|
||||
isClaudeMode: terminal.isClaudeMode,
|
||||
claudeSessionId: terminal.claudeSessionId,
|
||||
outputBuffer: terminal.outputBuffer,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActiveAt: new Date().toISOString()
|
||||
};
|
||||
store.saveSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist all active sessions
|
||||
*/
|
||||
export function persistAllSessions(terminals: Map<string, TerminalProcess>): void {
|
||||
const store = getTerminalSessionStore();
|
||||
|
||||
terminals.forEach((terminal) => {
|
||||
if (terminal.projectPath) {
|
||||
persistSession(terminal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a session from persistent storage
|
||||
*/
|
||||
export function removePersistedSession(terminal: TerminalProcess): void {
|
||||
if (!terminal.projectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = getTerminalSessionStore();
|
||||
store.removeSession(terminal.projectPath, terminal.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Claude session ID in persistent storage
|
||||
*/
|
||||
export function updateClaudeSessionId(
|
||||
projectPath: string,
|
||||
terminalId: string,
|
||||
sessionId: string
|
||||
): void {
|
||||
const store = getTerminalSessionStore();
|
||||
store.updateClaudeSessionId(projectPath, terminalId, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved sessions for a project
|
||||
*/
|
||||
export function getSavedSessions(projectPath: string): TerminalSession[] {
|
||||
const store = getTerminalSessionStore();
|
||||
return store.getSessions(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all saved sessions for a project
|
||||
*/
|
||||
export function clearSavedSessions(projectPath: string): void {
|
||||
const store = getTerminalSessionStore();
|
||||
store.clearProjectSessions(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available session dates
|
||||
*/
|
||||
export function getAvailableSessionDates(
|
||||
projectPath?: string
|
||||
): import('../terminal-session-store').SessionDateInfo[] {
|
||||
const store = getTerminalSessionStore();
|
||||
return store.getAvailableDates(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sessions for a specific date
|
||||
*/
|
||||
export function getSessionsForDate(date: string, projectPath: string): TerminalSession[] {
|
||||
const store = getTerminalSessionStore();
|
||||
return store.getSessionsForDate(date, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to capture Claude session ID by polling the session directory
|
||||
*/
|
||||
export function captureClaudeSessionId(
|
||||
terminalId: string,
|
||||
projectPath: string,
|
||||
startTime: number,
|
||||
terminals: Map<string, TerminalProcess>,
|
||||
getWindow: WindowGetter
|
||||
): void {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
const checkForSession = () => {
|
||||
attempts++;
|
||||
|
||||
const terminal = terminals.get(terminalId);
|
||||
if (!terminal || !terminal.isClaudeMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (terminal.claudeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = findClaudeSessionAfter(projectPath, startTime);
|
||||
|
||||
if (sessionId) {
|
||||
terminal.claudeSessionId = sessionId;
|
||||
console.log('[SessionHandler] Captured Claude session ID from directory:', sessionId);
|
||||
|
||||
if (terminal.projectPath) {
|
||||
updateClaudeSessionId(terminal.projectPath, terminalId, sessionId);
|
||||
}
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminalId, sessionId);
|
||||
}
|
||||
} else if (attempts < maxAttempts) {
|
||||
setTimeout(checkForSession, 1000);
|
||||
} else {
|
||||
console.log('[SessionHandler] Could not capture Claude session ID after', maxAttempts, 'attempts');
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(checkForSession, 2000);
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* Terminal Manager
|
||||
* Main orchestrator for terminal lifecycle, Claude integration, and profile management
|
||||
*/
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { TerminalCreateOptions } from '../../shared/types';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import type { TerminalSession } from '../terminal-session-store';
|
||||
|
||||
// Internal modules
|
||||
import type {
|
||||
TerminalProcess,
|
||||
WindowGetter,
|
||||
TerminalOperationResult,
|
||||
RateLimitEvent,
|
||||
OAuthTokenEvent
|
||||
} from './types';
|
||||
import * as OutputParser from './output-parser';
|
||||
import * as PtyManager from './pty-manager';
|
||||
import * as SessionHandler from './session-handler';
|
||||
|
||||
export class TerminalManager {
|
||||
private terminals: Map<string, TerminalProcess> = new Map();
|
||||
private getWindow: WindowGetter;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
private lastNotifiedRateLimitReset: Map<string, string> = new Map();
|
||||
|
||||
constructor(getWindow: WindowGetter) {
|
||||
this.getWindow = getWindow;
|
||||
|
||||
// Periodically save session data (every 30 seconds)
|
||||
this.saveTimer = setInterval(() => {
|
||||
SessionHandler.persistAllSessions(this.terminals);
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal process
|
||||
*/
|
||||
async create(
|
||||
options: TerminalCreateOptions & { projectPath?: string }
|
||||
): Promise<TerminalOperationResult> {
|
||||
const { id, cwd, cols = 80, rows = 24, projectPath } = options;
|
||||
|
||||
console.log('[TerminalManager] Creating terminal:', { id, cwd, cols, rows, projectPath });
|
||||
|
||||
if (this.terminals.has(id)) {
|
||||
console.log('[TerminalManager] Terminal already exists, returning success:', id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const profileEnv = PtyManager.getActiveProfileEnv();
|
||||
|
||||
if (profileEnv.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
console.log('[TerminalManager] Injecting OAuth token from active profile');
|
||||
}
|
||||
|
||||
const ptyProcess = PtyManager.spawnPtyProcess(
|
||||
cwd || os.homedir(),
|
||||
cols,
|
||||
rows,
|
||||
profileEnv
|
||||
);
|
||||
|
||||
console.log('[TerminalManager] PTY process spawned, pid:', ptyProcess.pid);
|
||||
|
||||
const terminalCwd = cwd || os.homedir();
|
||||
const terminal: TerminalProcess = {
|
||||
id,
|
||||
pty: ptyProcess,
|
||||
isClaudeMode: false,
|
||||
projectPath,
|
||||
cwd: terminalCwd,
|
||||
outputBuffer: '',
|
||||
title: `Terminal ${this.terminals.size + 1}`
|
||||
};
|
||||
|
||||
this.terminals.set(id, terminal);
|
||||
|
||||
PtyManager.setupPtyHandlers(
|
||||
terminal,
|
||||
this.terminals,
|
||||
this.getWindow,
|
||||
(term, data) => this.handleTerminalData(term, data),
|
||||
(term) => this.handleTerminalExit(term)
|
||||
);
|
||||
|
||||
if (projectPath) {
|
||||
SessionHandler.persistSession(terminal);
|
||||
}
|
||||
|
||||
console.log('[TerminalManager] Terminal created successfully:', id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[TerminalManager] Error creating terminal:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create terminal',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle data output from terminal
|
||||
*/
|
||||
private handleTerminalData(terminal: TerminalProcess, data: string): void {
|
||||
// Try to extract Claude session ID
|
||||
if (terminal.isClaudeMode && !terminal.claudeSessionId) {
|
||||
const sessionId = OutputParser.extractClaudeSessionId(data);
|
||||
if (sessionId) {
|
||||
this.handleClaudeSessionIdCaptured(terminal, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for rate limit messages
|
||||
if (terminal.isClaudeMode) {
|
||||
this.checkForRateLimit(terminal, data);
|
||||
}
|
||||
|
||||
// Check for OAuth token
|
||||
this.checkForOAuthToken(terminal, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle terminal exit event
|
||||
*/
|
||||
private handleTerminalExit(terminal: TerminalProcess): void {
|
||||
SessionHandler.removePersistedSession(terminal);
|
||||
this.lastNotifiedRateLimitReset.delete(terminal.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Claude session ID capture
|
||||
*/
|
||||
private handleClaudeSessionIdCaptured(terminal: TerminalProcess, sessionId: string): void {
|
||||
terminal.claudeSessionId = sessionId;
|
||||
console.log('[TerminalManager] Captured Claude session ID:', sessionId);
|
||||
|
||||
if (terminal.projectPath) {
|
||||
SessionHandler.updateClaudeSessionId(terminal.projectPath, terminal.id, sessionId);
|
||||
}
|
||||
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminal.id, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for rate limit messages and handle profile switching
|
||||
*/
|
||||
private checkForRateLimit(terminal: TerminalProcess, data: string): void {
|
||||
const resetTime = OutputParser.extractRateLimitReset(data);
|
||||
if (!resetTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastNotifiedReset = this.lastNotifiedRateLimitReset.get(terminal.id);
|
||||
if (resetTime === lastNotifiedReset) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastNotifiedRateLimitReset.set(terminal.id, resetTime);
|
||||
console.log('[TerminalManager] Rate limit detected, reset:', resetTime);
|
||||
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const currentProfileId = terminal.claudeProfileId || 'default';
|
||||
|
||||
try {
|
||||
const rateLimitEvent = profileManager.recordRateLimitEvent(currentProfileId, resetTime);
|
||||
console.log('[TerminalManager] Recorded rate limit event:', rateLimitEvent.type);
|
||||
} catch (err) {
|
||||
console.error('[TerminalManager] Failed to record rate limit event:', err);
|
||||
}
|
||||
|
||||
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
|
||||
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
|
||||
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, {
|
||||
terminalId: terminal.id,
|
||||
resetTime,
|
||||
detectedAt: new Date().toISOString(),
|
||||
profileId: currentProfileId,
|
||||
suggestedProfileId: bestProfile?.id,
|
||||
suggestedProfileName: bestProfile?.name,
|
||||
autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit
|
||||
} as RateLimitEvent);
|
||||
}
|
||||
|
||||
if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestProfile) {
|
||||
console.log('[TerminalManager] Auto-switching to profile:', bestProfile.name);
|
||||
this.switchClaudeProfile(terminal.id, bestProfile.id).then(result => {
|
||||
if (result.success) {
|
||||
console.log('[TerminalManager] Auto-switch successful');
|
||||
} else {
|
||||
console.error('[TerminalManager] Auto-switch failed:', result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for OAuth token and auto-save to profile
|
||||
*/
|
||||
private checkForOAuthToken(terminal: TerminalProcess, data: string): void {
|
||||
const token = OutputParser.extractOAuthToken(data);
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TerminalManager] OAuth token detected, length:', token.length);
|
||||
|
||||
const email = OutputParser.extractEmail(terminal.outputBuffer);
|
||||
const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+)-/);
|
||||
|
||||
if (profileIdMatch) {
|
||||
const profileId = profileIdMatch[1];
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setProfileToken(profileId, token, email || undefined);
|
||||
|
||||
if (success) {
|
||||
console.log('[TerminalManager] OAuth token auto-saved to profile:', profileId);
|
||||
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
email,
|
||||
success: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
} else {
|
||||
console.error('[TerminalManager] Failed to save OAuth token to profile:', profileId);
|
||||
}
|
||||
} else {
|
||||
console.log('[TerminalManager] OAuth token detected but not in a profile login terminal');
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
email,
|
||||
success: false,
|
||||
message: 'Token detected but no profile associated with this terminal',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a terminal session
|
||||
*/
|
||||
async restore(
|
||||
session: TerminalSession,
|
||||
cols = 80,
|
||||
rows = 24
|
||||
): Promise<TerminalOperationResult> {
|
||||
console.log('[TerminalManager] Restoring terminal session:', session.id, 'Claude mode:', session.isClaudeMode);
|
||||
|
||||
const result = await this.create({
|
||||
id: session.id,
|
||||
cwd: session.cwd,
|
||||
cols,
|
||||
rows,
|
||||
projectPath: session.projectPath
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const terminal = this.terminals.get(session.id);
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found after creation' };
|
||||
}
|
||||
|
||||
terminal.title = session.title;
|
||||
|
||||
if (session.isClaudeMode) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
terminal.isClaudeMode = true;
|
||||
terminal.claudeSessionId = session.claudeSessionId;
|
||||
|
||||
const projectDir = session.cwd || session.projectPath;
|
||||
const startTime = Date.now();
|
||||
const clearCmd = process.platform === 'win32' ? 'cls' : 'clear';
|
||||
|
||||
let resumeCommand: string;
|
||||
if (session.claudeSessionId) {
|
||||
resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume "${session.claudeSessionId}"`;
|
||||
console.log('[TerminalManager] Resuming Claude with session ID:', session.claudeSessionId, 'in', projectDir);
|
||||
} else {
|
||||
resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume`;
|
||||
console.log('[TerminalManager] Opening Claude session picker in', projectDir);
|
||||
}
|
||||
|
||||
terminal.pty.write(`${resumeCommand}\r`);
|
||||
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, session.id, 'Claude');
|
||||
}
|
||||
|
||||
if (!session.claudeSessionId && projectDir) {
|
||||
SessionHandler.captureClaudeSessionId(
|
||||
session.id,
|
||||
projectDir,
|
||||
startTime,
|
||||
this.terminals,
|
||||
this.getWindow
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
outputBuffer: session.outputBuffer
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a terminal process
|
||||
*/
|
||||
async destroy(id: string): Promise<TerminalOperationResult> {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
SessionHandler.removePersistedSession(terminal);
|
||||
this.lastNotifiedRateLimitReset.delete(id);
|
||||
PtyManager.killPty(terminal);
|
||||
this.terminals.delete(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to destroy terminal',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to a terminal
|
||||
*/
|
||||
write(id: string, data: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
PtyManager.writeToPty(terminal, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a terminal
|
||||
*/
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
PtyManager.resizePty(terminal, cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke Claude in a terminal with optional profile override
|
||||
*/
|
||||
invokeClaude(id: string, cwd?: string, profileId?: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.isClaudeMode = true;
|
||||
terminal.claudeSessionId = undefined;
|
||||
|
||||
const startTime = Date.now();
|
||||
const projectPath = cwd || terminal.projectPath || terminal.cwd;
|
||||
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileId
|
||||
? profileManager.getProfile(profileId)
|
||||
: profileManager.getActiveProfile();
|
||||
|
||||
const previousProfileId = terminal.claudeProfileId;
|
||||
terminal.claudeProfileId = activeProfile?.id;
|
||||
|
||||
const cwdCommand = cwd ? `cd "${cwd}" && ` : '';
|
||||
const needsEnvOverride = profileId && profileId !== previousProfileId;
|
||||
|
||||
if (needsEnvOverride && activeProfile && !activeProfile.isDefault) {
|
||||
const token = profileManager.getProfileToken(activeProfile.id);
|
||||
|
||||
if (token) {
|
||||
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}`);
|
||||
fs.writeFileSync(tempFile, `export CLAUDE_CODE_OAUTH_TOKEN="${token}"\n`, { mode: 0o600 });
|
||||
|
||||
terminal.pty.write(`${cwdCommand}source "${tempFile}" && rm -f "${tempFile}" && claude\r`);
|
||||
console.log('[TerminalManager] Switching to Claude profile:', activeProfile.name, '(via secure temp file)');
|
||||
return;
|
||||
} else if (activeProfile.configDir) {
|
||||
terminal.pty.write(`${cwdCommand}CLAUDE_CONFIG_DIR="${activeProfile.configDir}" claude\r`);
|
||||
console.log('[TerminalManager] Using Claude profile:', activeProfile.name, 'config:', activeProfile.configDir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeProfile && !activeProfile.isDefault) {
|
||||
console.log('[TerminalManager] Using Claude profile:', activeProfile.name, '(from terminal environment)');
|
||||
}
|
||||
|
||||
terminal.pty.write(`${cwdCommand}claude\r`);
|
||||
|
||||
if (activeProfile) {
|
||||
profileManager.markProfileUsed(activeProfile.id);
|
||||
}
|
||||
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
const title = activeProfile && !activeProfile.isDefault
|
||||
? `Claude (${activeProfile.name})`
|
||||
: 'Claude';
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, id, title);
|
||||
}
|
||||
|
||||
if (terminal.projectPath) {
|
||||
SessionHandler.persistSession(terminal);
|
||||
}
|
||||
|
||||
if (projectPath) {
|
||||
SessionHandler.captureClaudeSessionId(
|
||||
id,
|
||||
projectPath,
|
||||
startTime,
|
||||
this.terminals,
|
||||
this.getWindow
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch a terminal to a different Claude profile
|
||||
*/
|
||||
async switchClaudeProfile(id: string, profileId: string): Promise<TerminalOperationResult> {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (!terminal) {
|
||||
return { success: false, error: 'Terminal not found' };
|
||||
}
|
||||
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = profileManager.getProfile(profileId);
|
||||
if (!profile) {
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
console.log('[TerminalManager] Switching to Claude profile:', profile.name);
|
||||
|
||||
if (terminal.isClaudeMode) {
|
||||
terminal.pty.write('\x03');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
terminal.pty.write('/exit\r');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
this.lastNotifiedRateLimitReset.delete(id);
|
||||
|
||||
const projectPath = terminal.projectPath || terminal.cwd;
|
||||
this.invokeClaude(id, projectPath, profileId);
|
||||
|
||||
profileManager.setActiveProfile(profileId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume Claude in a terminal with a specific session ID
|
||||
*/
|
||||
resumeClaude(id: string, sessionId?: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.isClaudeMode = true;
|
||||
|
||||
let command: string;
|
||||
if (sessionId) {
|
||||
command = `claude --resume "${sessionId}"`;
|
||||
terminal.claudeSessionId = sessionId;
|
||||
} else {
|
||||
command = 'claude --continue';
|
||||
}
|
||||
|
||||
terminal.pty.write(`${command}\r`);
|
||||
|
||||
const win = this.getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, id, 'Claude');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved sessions for a project
|
||||
*/
|
||||
getSavedSessions(projectPath: string): TerminalSession[] {
|
||||
return SessionHandler.getSavedSessions(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear saved sessions for a project
|
||||
*/
|
||||
clearSavedSessions(projectPath: string): void {
|
||||
SessionHandler.clearSavedSessions(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available session dates
|
||||
*/
|
||||
getAvailableSessionDates(projectPath?: string): import('../terminal-session-store').SessionDateInfo[] {
|
||||
return SessionHandler.getAvailableSessionDates(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sessions for a specific date
|
||||
*/
|
||||
getSessionsForDate(date: string, projectPath: string): TerminalSession[] {
|
||||
return SessionHandler.getSessionsForDate(date, projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all sessions from a specific date
|
||||
*/
|
||||
async restoreSessionsFromDate(
|
||||
date: string,
|
||||
projectPath: string,
|
||||
cols = 80,
|
||||
rows = 24
|
||||
): Promise<{ restored: number; failed: number; sessions: Array<{ id: string; success: boolean; error?: string }> }> {
|
||||
const sessions = SessionHandler.getSessionsForDate(date, projectPath);
|
||||
const results: Array<{ id: string; success: boolean; error?: string }> = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const result = await this.restore(session, cols, rows);
|
||||
results.push({
|
||||
id: session.id,
|
||||
success: result.success,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
restored: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length,
|
||||
sessions: results
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all terminal processes
|
||||
*/
|
||||
async killAll(): Promise<void> {
|
||||
SessionHandler.persistAllSessions(this.terminals);
|
||||
|
||||
if (this.saveTimer) {
|
||||
clearInterval(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
this.terminals.forEach((terminal) => {
|
||||
promises.push(
|
||||
new Promise((resolve) => {
|
||||
try {
|
||||
PtyManager.killPty(terminal);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
this.terminals.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active terminal IDs
|
||||
*/
|
||||
getActiveTerminalIds(): string[] {
|
||||
return Array.from(this.terminals.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a terminal is in Claude mode
|
||||
*/
|
||||
isClaudeMode(id: string): boolean {
|
||||
const terminal = this.terminals.get(id);
|
||||
return terminal?.isClaudeMode ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Claude session ID for a terminal
|
||||
*/
|
||||
getClaudeSessionId(id: string): string | undefined {
|
||||
const terminal = this.terminals.get(id);
|
||||
return terminal?.claudeSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update terminal title
|
||||
*/
|
||||
setTitle(id: string, title: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
terminal.title = title;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type * as pty from 'node-pty';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
/**
|
||||
* Terminal process tracking
|
||||
*/
|
||||
export interface TerminalProcess {
|
||||
id: string;
|
||||
pty: pty.IPty;
|
||||
isClaudeMode: boolean;
|
||||
projectPath?: string;
|
||||
cwd: string;
|
||||
claudeSessionId?: string;
|
||||
claudeProfileId?: string;
|
||||
outputBuffer: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit event data
|
||||
*/
|
||||
export interface RateLimitEvent {
|
||||
terminalId: string;
|
||||
resetTime: string;
|
||||
detectedAt: string;
|
||||
profileId: string;
|
||||
suggestedProfileId?: string;
|
||||
suggestedProfileName?: string;
|
||||
autoSwitchEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth token event data
|
||||
*/
|
||||
export interface OAuthTokenEvent {
|
||||
terminalId: string;
|
||||
profileId?: string;
|
||||
email?: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session capture result
|
||||
*/
|
||||
export interface SessionCaptureResult {
|
||||
sessionId: string | null;
|
||||
captured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal creation result
|
||||
*/
|
||||
export interface TerminalOperationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
outputBuffer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Window getter function type
|
||||
*/
|
||||
export type WindowGetter = () => BrowserWindow | null;
|
||||
@@ -0,0 +1,656 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
Task,
|
||||
IPCResult,
|
||||
Roadmap,
|
||||
RoadmapFeatureStatus,
|
||||
RoadmapGenerationStatus,
|
||||
IdeationSession,
|
||||
IdeationConfig,
|
||||
IdeationStatus,
|
||||
IdeationGenerationStatus,
|
||||
Idea,
|
||||
LinearTeam,
|
||||
LinearProject,
|
||||
LinearIssue,
|
||||
LinearImportResult,
|
||||
LinearSyncStatus,
|
||||
GitHubRepository,
|
||||
GitHubIssue,
|
||||
GitHubSyncStatus,
|
||||
GitHubImportResult,
|
||||
GitHubInvestigationStatus,
|
||||
GitHubInvestigationResult,
|
||||
AutoBuildSourceUpdateCheck,
|
||||
AutoBuildSourceUpdateProgress,
|
||||
ChangelogTask,
|
||||
TaskSpecContent,
|
||||
ChangelogGenerationRequest,
|
||||
ChangelogGenerationResult,
|
||||
ChangelogSaveRequest,
|
||||
ChangelogSaveResult,
|
||||
ChangelogGenerationProgress,
|
||||
ExistingChangelog,
|
||||
GitBranchInfo,
|
||||
GitTagInfo,
|
||||
GitCommit,
|
||||
GitHistoryOptions,
|
||||
BranchDiffOptions,
|
||||
InsightsSession,
|
||||
InsightsSessionSummary,
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk,
|
||||
TaskMetadata
|
||||
} from '../../shared/types';
|
||||
|
||||
export interface AgentAPI {
|
||||
// Roadmap Operations
|
||||
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
|
||||
generateRoadmap: (projectId: string) => void;
|
||||
refreshRoadmap: (projectId: string) => void;
|
||||
updateFeatureStatus: (
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
status: RoadmapFeatureStatus
|
||||
) => Promise<IPCResult>;
|
||||
convertFeatureToSpec: (
|
||||
projectId: string,
|
||||
featureId: string
|
||||
) => Promise<IPCResult<Task>>;
|
||||
|
||||
// Roadmap Event Listeners
|
||||
onRoadmapProgress: (callback: (projectId: string, status: RoadmapGenerationStatus) => void) => () => void;
|
||||
onRoadmapComplete: (callback: (projectId: string, roadmap: Roadmap) => void) => () => void;
|
||||
onRoadmapError: (callback: (projectId: string, error: string) => void) => () => void;
|
||||
|
||||
// Ideation Operations
|
||||
getIdeation: (projectId: string) => Promise<IPCResult<IdeationSession | null>>;
|
||||
generateIdeation: (projectId: string, config: IdeationConfig) => void;
|
||||
refreshIdeation: (projectId: string, config: IdeationConfig) => void;
|
||||
stopIdeation: (projectId: string) => Promise<IPCResult>;
|
||||
updateIdeaStatus: (projectId: string, ideaId: string, status: IdeationStatus) => Promise<IPCResult>;
|
||||
convertIdeaToTask: (projectId: string, ideaId: string) => Promise<IPCResult<Task>>;
|
||||
dismissIdea: (projectId: string, ideaId: string) => Promise<IPCResult>;
|
||||
dismissAllIdeas: (projectId: string) => Promise<IPCResult>;
|
||||
|
||||
// Ideation Event Listeners
|
||||
onIdeationProgress: (callback: (projectId: string, status: IdeationGenerationStatus) => void) => () => void;
|
||||
onIdeationLog: (callback: (projectId: string, log: string) => void) => () => void;
|
||||
onIdeationComplete: (callback: (projectId: string, session: IdeationSession) => void) => () => void;
|
||||
onIdeationError: (callback: (projectId: string, error: string) => void) => () => void;
|
||||
onIdeationStopped: (callback: (projectId: string) => void) => () => void;
|
||||
onIdeationTypeComplete: (callback: (projectId: string, ideationType: string, ideas: Idea[]) => void) => () => void;
|
||||
onIdeationTypeFailed: (callback: (projectId: string, ideationType: string) => void) => () => void;
|
||||
|
||||
// Insights Operations
|
||||
getInsightsSession: (projectId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
sendInsightsMessage: (projectId: string, message: string) => void;
|
||||
clearInsightsSession: (projectId: string) => Promise<IPCResult>;
|
||||
createTaskFromInsights: (
|
||||
projectId: string,
|
||||
title: string,
|
||||
description: string,
|
||||
metadata?: TaskMetadata
|
||||
) => Promise<IPCResult<Task>>;
|
||||
listInsightsSessions: (projectId: string) => Promise<IPCResult<InsightsSessionSummary[]>>;
|
||||
newInsightsSession: (projectId: string) => Promise<IPCResult<InsightsSession>>;
|
||||
switchInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
deleteInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult>;
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string) => Promise<IPCResult>;
|
||||
|
||||
// Insights Event Listeners
|
||||
onInsightsStreamChunk: (callback: (projectId: string, chunk: InsightsStreamChunk) => void) => () => void;
|
||||
onInsightsStatus: (callback: (projectId: string, status: InsightsChatStatus) => void) => () => void;
|
||||
onInsightsError: (callback: (projectId: string, error: string) => void) => () => void;
|
||||
|
||||
// Changelog Operations
|
||||
getChangelogDoneTasks: (projectId: string, tasks?: Task[]) => Promise<IPCResult<ChangelogTask[]>>;
|
||||
loadTaskSpecs: (projectId: string, taskIds: string[]) => Promise<IPCResult<TaskSpecContent[]>>;
|
||||
generateChangelog: (request: ChangelogGenerationRequest) => void;
|
||||
saveChangelog: (request: ChangelogSaveRequest) => Promise<IPCResult<ChangelogSaveResult>>;
|
||||
readExistingChangelog: (projectId: string) => Promise<IPCResult<ExistingChangelog>>;
|
||||
suggestChangelogVersion: (
|
||||
projectId: string,
|
||||
taskIds: string[]
|
||||
) => Promise<IPCResult<{ version: string; reason: string }>>;
|
||||
getChangelogBranches: (projectId: string) => Promise<IPCResult<GitBranchInfo[]>>;
|
||||
getChangelogTags: (projectId: string) => Promise<IPCResult<GitTagInfo[]>>;
|
||||
getChangelogCommitsPreview: (
|
||||
projectId: string,
|
||||
options: GitHistoryOptions | BranchDiffOptions,
|
||||
mode: 'git-history' | 'branch-diff'
|
||||
) => Promise<IPCResult<GitCommit[]>>;
|
||||
|
||||
// Changelog Event Listeners
|
||||
onChangelogGenerationProgress: (callback: (projectId: string, progress: ChangelogGenerationProgress) => void) => () => void;
|
||||
onChangelogGenerationComplete: (callback: (projectId: string, result: ChangelogGenerationResult) => void) => () => void;
|
||||
onChangelogGenerationError: (callback: (projectId: string, error: string) => void) => () => void;
|
||||
|
||||
// Linear Integration
|
||||
getLinearTeams: (projectId: string) => Promise<IPCResult<LinearTeam[]>>;
|
||||
getLinearProjects: (projectId: string, teamId: string) => Promise<IPCResult<LinearProject[]>>;
|
||||
getLinearIssues: (projectId: string, teamId?: string, linearProjectId?: string) => Promise<IPCResult<LinearIssue[]>>;
|
||||
importLinearIssues: (projectId: string, issueIds: string[]) => Promise<IPCResult<LinearImportResult>>;
|
||||
checkLinearConnection: (projectId: string) => Promise<IPCResult<LinearSyncStatus>>;
|
||||
|
||||
// GitHub Integration
|
||||
getGitHubRepositories: (projectId: string) => Promise<IPCResult<GitHubRepository[]>>;
|
||||
getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise<IPCResult<GitHubIssue[]>>;
|
||||
getGitHubIssue: (projectId: string, issueNumber: number) => Promise<IPCResult<GitHubIssue>>;
|
||||
checkGitHubConnection: (projectId: string) => Promise<IPCResult<GitHubSyncStatus>>;
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number) => void;
|
||||
importGitHubIssues: (projectId: string, issueNumbers: number[]) => Promise<IPCResult<GitHubImportResult>>;
|
||||
createGitHubRelease: (
|
||||
projectId: string,
|
||||
version: string,
|
||||
releaseNotes: string,
|
||||
options?: { draft?: boolean; prerelease?: boolean }
|
||||
) => Promise<IPCResult<{ url: string }>>;
|
||||
|
||||
// GitHub Event Listeners
|
||||
onGitHubInvestigationProgress: (callback: (projectId: string, status: GitHubInvestigationStatus) => void) => () => void;
|
||||
onGitHubInvestigationComplete: (callback: (projectId: string, result: GitHubInvestigationResult) => void) => () => void;
|
||||
onGitHubInvestigationError: (callback: (projectId: string, error: string) => void) => () => void;
|
||||
|
||||
// Auto-Build Source Update
|
||||
checkAutoBuildSourceUpdate: () => Promise<IPCResult<AutoBuildSourceUpdateCheck>>;
|
||||
downloadAutoBuildSourceUpdate: () => void;
|
||||
getAutoBuildSourceVersion: () => Promise<IPCResult<string>>;
|
||||
onAutoBuildSourceUpdateProgress: (callback: (progress: AutoBuildSourceUpdateProgress) => void) => () => void;
|
||||
}
|
||||
|
||||
export const createAgentAPI = (): AgentAPI => ({
|
||||
// Roadmap Operations
|
||||
getRoadmap: (projectId: string): Promise<IPCResult<Roadmap | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_GET, projectId),
|
||||
|
||||
generateRoadmap: (projectId: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.ROADMAP_GENERATE, projectId),
|
||||
|
||||
refreshRoadmap: (projectId: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.ROADMAP_REFRESH, projectId),
|
||||
|
||||
updateFeatureStatus: (
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
status: RoadmapFeatureStatus
|
||||
): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_UPDATE_FEATURE, projectId, featureId, status),
|
||||
|
||||
convertFeatureToSpec: (
|
||||
projectId: string,
|
||||
featureId: string
|
||||
): Promise<IPCResult<Task>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, projectId, featureId),
|
||||
|
||||
// Roadmap Event Listeners
|
||||
onRoadmapProgress: (
|
||||
callback: (projectId: string, status: RoadmapGenerationStatus) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
status: RoadmapGenerationStatus
|
||||
): void => {
|
||||
callback(projectId, status);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.ROADMAP_PROGRESS, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.ROADMAP_PROGRESS, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onRoadmapComplete: (
|
||||
callback: (projectId: string, roadmap: Roadmap) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
roadmap: Roadmap
|
||||
): void => {
|
||||
callback(projectId, roadmap);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.ROADMAP_COMPLETE, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.ROADMAP_COMPLETE, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onRoadmapError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
error: string
|
||||
): void => {
|
||||
callback(projectId, error);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.ROADMAP_ERROR, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.ROADMAP_ERROR, handler);
|
||||
};
|
||||
},
|
||||
|
||||
// Ideation Operations
|
||||
getIdeation: (projectId: string): Promise<IPCResult<IdeationSession | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.IDEATION_GET, projectId),
|
||||
|
||||
generateIdeation: (projectId: string, config: IdeationConfig): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.IDEATION_GENERATE, projectId, config),
|
||||
|
||||
refreshIdeation: (projectId: string, config: IdeationConfig): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.IDEATION_REFRESH, projectId, config),
|
||||
|
||||
stopIdeation: (projectId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.IDEATION_STOP, projectId),
|
||||
|
||||
updateIdeaStatus: (projectId: string, ideaId: string, status: IdeationStatus): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.IDEATION_UPDATE_IDEA, projectId, ideaId, status),
|
||||
|
||||
convertIdeaToTask: (projectId: string, ideaId: string): Promise<IPCResult<Task>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.IDEATION_CONVERT_TO_TASK, projectId, ideaId),
|
||||
|
||||
dismissIdea: (projectId: string, ideaId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.IDEATION_DISMISS, projectId, ideaId),
|
||||
|
||||
dismissAllIdeas: (projectId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.IDEATION_DISMISS_ALL, projectId),
|
||||
|
||||
// Ideation Event Listeners
|
||||
onIdeationProgress: (
|
||||
callback: (projectId: string, status: IdeationGenerationStatus) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
status: IdeationGenerationStatus
|
||||
): void => {
|
||||
callback(projectId, status);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.IDEATION_PROGRESS, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_PROGRESS, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onIdeationLog: (
|
||||
callback: (projectId: string, log: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
log: string
|
||||
): void => {
|
||||
callback(projectId, log);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.IDEATION_LOG, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_LOG, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onIdeationComplete: (
|
||||
callback: (projectId: string, session: IdeationSession) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
session: IdeationSession
|
||||
): void => {
|
||||
callback(projectId, session);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.IDEATION_COMPLETE, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_COMPLETE, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onIdeationError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
error: string
|
||||
): void => {
|
||||
callback(projectId, error);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.IDEATION_ERROR, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_ERROR, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onIdeationStopped: (
|
||||
callback: (projectId: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string
|
||||
): void => {
|
||||
callback(projectId);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.IDEATION_STOPPED, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_STOPPED, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onIdeationTypeComplete: (
|
||||
callback: (projectId: string, ideationType: string, ideas: Idea[]) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
ideationType: string,
|
||||
ideas: Idea[]
|
||||
): void => {
|
||||
callback(projectId, ideationType, ideas);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onIdeationTypeFailed: (
|
||||
callback: (projectId: string, ideationType: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
ideationType: string
|
||||
): void => {
|
||||
callback(projectId, ideationType);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.IDEATION_TYPE_FAILED, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_TYPE_FAILED, handler);
|
||||
};
|
||||
},
|
||||
|
||||
// Insights Operations
|
||||
getInsightsSession: (projectId: string): Promise<IPCResult<InsightsSession | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_GET_SESSION, projectId),
|
||||
|
||||
sendInsightsMessage: (projectId: string, message: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message),
|
||||
|
||||
clearInsightsSession: (projectId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_CLEAR_SESSION, projectId),
|
||||
|
||||
createTaskFromInsights: (
|
||||
projectId: string,
|
||||
title: string,
|
||||
description: string,
|
||||
metadata?: TaskMetadata
|
||||
): Promise<IPCResult<Task>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_CREATE_TASK, projectId, title, description, metadata),
|
||||
|
||||
listInsightsSessions: (projectId: string): Promise<IPCResult<InsightsSessionSummary[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_LIST_SESSIONS, projectId),
|
||||
|
||||
newInsightsSession: (projectId: string): Promise<IPCResult<InsightsSession>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_NEW_SESSION, projectId),
|
||||
|
||||
switchInsightsSession: (projectId: string, sessionId: string): Promise<IPCResult<InsightsSession | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_SWITCH_SESSION, projectId, sessionId),
|
||||
|
||||
deleteInsightsSession: (projectId: string, sessionId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_DELETE_SESSION, projectId, sessionId),
|
||||
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_RENAME_SESSION, projectId, sessionId, newTitle),
|
||||
|
||||
// Insights Event Listeners
|
||||
onInsightsStreamChunk: (
|
||||
callback: (projectId: string, chunk: InsightsStreamChunk) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
chunk: InsightsStreamChunk
|
||||
): void => {
|
||||
callback(projectId, chunk);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onInsightsStatus: (
|
||||
callback: (projectId: string, status: InsightsChatStatus) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
status: InsightsChatStatus
|
||||
): void => {
|
||||
callback(projectId, status);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.INSIGHTS_STATUS, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_STATUS, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onInsightsError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
error: string
|
||||
): void => {
|
||||
callback(projectId, error);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.INSIGHTS_ERROR, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_ERROR, handler);
|
||||
};
|
||||
},
|
||||
|
||||
// Changelog Operations
|
||||
getChangelogDoneTasks: (projectId: string, tasks?: Task[]): Promise<IPCResult<ChangelogTask[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS, projectId, tasks),
|
||||
|
||||
loadTaskSpecs: (projectId: string, taskIds: string[]): Promise<IPCResult<TaskSpecContent[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_LOAD_TASK_SPECS, projectId, taskIds),
|
||||
|
||||
generateChangelog: (request: ChangelogGenerationRequest): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.CHANGELOG_GENERATE, request),
|
||||
|
||||
saveChangelog: (request: ChangelogSaveRequest): Promise<IPCResult<ChangelogSaveResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_SAVE, request),
|
||||
|
||||
readExistingChangelog: (projectId: string): Promise<IPCResult<ExistingChangelog>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_READ_EXISTING, projectId),
|
||||
|
||||
suggestChangelogVersion: (
|
||||
projectId: string,
|
||||
taskIds: string[]
|
||||
): Promise<IPCResult<{ version: string; reason: string }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION, projectId, taskIds),
|
||||
|
||||
getChangelogBranches: (projectId: string): Promise<IPCResult<GitBranchInfo[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_BRANCHES, projectId),
|
||||
|
||||
getChangelogTags: (projectId: string): Promise<IPCResult<GitTagInfo[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_TAGS, projectId),
|
||||
|
||||
getChangelogCommitsPreview: (
|
||||
projectId: string,
|
||||
options: GitHistoryOptions | BranchDiffOptions,
|
||||
mode: 'git-history' | 'branch-diff'
|
||||
): Promise<IPCResult<GitCommit[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_COMMITS_PREVIEW, projectId, options, mode),
|
||||
|
||||
// Changelog Event Listeners
|
||||
onChangelogGenerationProgress: (
|
||||
callback: (projectId: string, progress: ChangelogGenerationProgress) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
progress: ChangelogGenerationProgress
|
||||
): void => {
|
||||
callback(projectId, progress);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.CHANGELOG_GENERATION_PROGRESS, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_PROGRESS, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onChangelogGenerationComplete: (
|
||||
callback: (projectId: string, result: ChangelogGenerationResult) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
result: ChangelogGenerationResult
|
||||
): void => {
|
||||
callback(projectId, result);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.CHANGELOG_GENERATION_COMPLETE, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_COMPLETE, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onChangelogGenerationError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
error: string
|
||||
): void => {
|
||||
callback(projectId, error);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, handler);
|
||||
};
|
||||
},
|
||||
|
||||
// Linear Integration
|
||||
getLinearTeams: (projectId: string): Promise<IPCResult<LinearTeam[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.LINEAR_GET_TEAMS, projectId),
|
||||
|
||||
getLinearProjects: (projectId: string, teamId: string): Promise<IPCResult<LinearProject[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.LINEAR_GET_PROJECTS, projectId, teamId),
|
||||
|
||||
getLinearIssues: (projectId: string, teamId?: string, linearProjectId?: string): Promise<IPCResult<LinearIssue[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.LINEAR_GET_ISSUES, projectId, teamId, linearProjectId),
|
||||
|
||||
importLinearIssues: (projectId: string, issueIds: string[]): Promise<IPCResult<LinearImportResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.LINEAR_IMPORT_ISSUES, projectId, issueIds),
|
||||
|
||||
checkLinearConnection: (projectId: string): Promise<IPCResult<LinearSyncStatus>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.LINEAR_CHECK_CONNECTION, projectId),
|
||||
|
||||
// GitHub Integration
|
||||
getGitHubRepositories: (projectId: string): Promise<IPCResult<GitHubRepository[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.GITHUB_GET_REPOSITORIES, projectId),
|
||||
|
||||
getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all'): Promise<IPCResult<GitHubIssue[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.GITHUB_GET_ISSUES, projectId, state),
|
||||
|
||||
getGitHubIssue: (projectId: string, issueNumber: number): Promise<IPCResult<GitHubIssue>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.GITHUB_GET_ISSUE, projectId, issueNumber),
|
||||
|
||||
checkGitHubConnection: (projectId: string): Promise<IPCResult<GitHubSyncStatus>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.GITHUB_CHECK_CONNECTION, projectId),
|
||||
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, projectId, issueNumber),
|
||||
|
||||
importGitHubIssues: (projectId: string, issueNumbers: number[]): Promise<IPCResult<GitHubImportResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.GITHUB_IMPORT_ISSUES, projectId, issueNumbers),
|
||||
|
||||
createGitHubRelease: (
|
||||
projectId: string,
|
||||
version: string,
|
||||
releaseNotes: string,
|
||||
options?: { draft?: boolean; prerelease?: boolean }
|
||||
): Promise<IPCResult<{ url: string }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.GITHUB_CREATE_RELEASE, projectId, version, releaseNotes, options),
|
||||
|
||||
// GitHub Event Listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
callback: (projectId: string, status: GitHubInvestigationStatus) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
status: GitHubInvestigationStatus
|
||||
): void => {
|
||||
callback(projectId, status);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onGitHubInvestigationComplete: (
|
||||
callback: (projectId: string, result: GitHubInvestigationResult) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
result: GitHubInvestigationResult
|
||||
): void => {
|
||||
callback(projectId, result);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onGitHubInvestigationError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
projectId: string,
|
||||
error: string
|
||||
): void => {
|
||||
callback(projectId, error);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, handler);
|
||||
};
|
||||
},
|
||||
|
||||
// Auto-Build Source Update
|
||||
checkAutoBuildSourceUpdate: (): Promise<IPCResult<AutoBuildSourceUpdateCheck>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK),
|
||||
|
||||
downloadAutoBuildSourceUpdate: (): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD),
|
||||
|
||||
getAutoBuildSourceVersion: (): Promise<IPCResult<string>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION),
|
||||
|
||||
onAutoBuildSourceUpdateProgress: (
|
||||
callback: (progress: AutoBuildSourceUpdateProgress) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
progress: AutoBuildSourceUpdateProgress
|
||||
): void => {
|
||||
callback(progress);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, handler);
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type { IPCResult } from '../../shared/types';
|
||||
|
||||
export interface FileAPI {
|
||||
// File Explorer Operations
|
||||
listDirectory: (dirPath: string) => Promise<IPCResult<import('../../shared/types').FileNode[]>>;
|
||||
}
|
||||
|
||||
export const createFileAPI = (): FileAPI => ({
|
||||
// File Explorer Operations
|
||||
listDirectory: (dirPath: string): Promise<IPCResult<import('../../shared/types').FileNode[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.FILE_EXPLORER_LIST, dirPath)
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ProjectAPI, createProjectAPI } from './project-api';
|
||||
import { TerminalAPI, createTerminalAPI } from './terminal-api';
|
||||
import { TaskAPI, createTaskAPI } from './task-api';
|
||||
import { SettingsAPI, createSettingsAPI } from './settings-api';
|
||||
import { FileAPI, createFileAPI } from './file-api';
|
||||
import { AgentAPI, createAgentAPI } from './agent-api';
|
||||
|
||||
export interface ElectronAPI extends
|
||||
ProjectAPI,
|
||||
TerminalAPI,
|
||||
TaskAPI,
|
||||
SettingsAPI,
|
||||
FileAPI,
|
||||
AgentAPI {}
|
||||
|
||||
export const createElectronAPI = (): ElectronAPI => ({
|
||||
...createProjectAPI(),
|
||||
...createTerminalAPI(),
|
||||
...createTaskAPI(),
|
||||
...createSettingsAPI(),
|
||||
...createFileAPI(),
|
||||
...createAgentAPI()
|
||||
});
|
||||
|
||||
// Export individual API creators for potential use in tests or specialized contexts
|
||||
export {
|
||||
createProjectAPI,
|
||||
createTerminalAPI,
|
||||
createTaskAPI,
|
||||
createSettingsAPI,
|
||||
createFileAPI,
|
||||
createAgentAPI
|
||||
};
|
||||
|
||||
export type {
|
||||
ProjectAPI,
|
||||
TerminalAPI,
|
||||
TaskAPI,
|
||||
SettingsAPI,
|
||||
FileAPI,
|
||||
AgentAPI
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSettings,
|
||||
IPCResult,
|
||||
InitializationResult,
|
||||
AutoBuildVersionInfo,
|
||||
ProjectContextData,
|
||||
ProjectIndex,
|
||||
GraphitiMemoryStatus,
|
||||
ContextSearchResult,
|
||||
MemoryEpisode,
|
||||
ProjectEnvConfig,
|
||||
ClaudeAuthResult
|
||||
} from '../../shared/types';
|
||||
|
||||
export interface ProjectAPI {
|
||||
// Project Management
|
||||
addProject: (projectPath: string) => Promise<IPCResult<Project>>;
|
||||
removeProject: (projectId: string) => Promise<IPCResult>;
|
||||
getProjects: () => Promise<IPCResult<Project[]>>;
|
||||
updateProjectSettings: (
|
||||
projectId: string,
|
||||
settings: Partial<ProjectSettings>
|
||||
) => Promise<IPCResult>;
|
||||
initializeProject: (projectId: string) => Promise<IPCResult<InitializationResult>>;
|
||||
updateProjectAutoBuild: (projectId: string) => Promise<IPCResult<InitializationResult>>;
|
||||
checkProjectVersion: (projectId: string) => Promise<IPCResult<AutoBuildVersionInfo>>;
|
||||
|
||||
// Context Operations
|
||||
getProjectContext: (projectId: string) => Promise<any>;
|
||||
refreshProjectIndex: (projectId: string) => Promise<any>;
|
||||
getMemoryStatus: (projectId: string) => Promise<any>;
|
||||
searchMemories: (projectId: string, query: string) => Promise<any>;
|
||||
getRecentMemories: (projectId: string, limit?: number) => Promise<any>;
|
||||
|
||||
// Environment Configuration
|
||||
getProjectEnv: (projectId: string) => Promise<IPCResult<ProjectEnvConfig>>;
|
||||
updateProjectEnv: (projectId: string, config: Partial<ProjectEnvConfig>) => Promise<IPCResult>;
|
||||
checkClaudeAuth: (projectId: string) => Promise<IPCResult<ClaudeAuthResult>>;
|
||||
invokeClaudeSetup: (projectId: string) => Promise<IPCResult<ClaudeAuthResult>>;
|
||||
|
||||
// Dialog Operations
|
||||
selectDirectory: () => Promise<string | null>;
|
||||
createProjectFolder: (
|
||||
location: string,
|
||||
name: string,
|
||||
initGit: boolean
|
||||
) => Promise<IPCResult<import('../../shared/types').CreateProjectFolderResult>>;
|
||||
getDefaultProjectLocation: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
export const createProjectAPI = (): ProjectAPI => ({
|
||||
// Project Management
|
||||
addProject: (projectPath: string): Promise<IPCResult<Project>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_ADD, projectPath),
|
||||
|
||||
removeProject: (projectId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_REMOVE, projectId),
|
||||
|
||||
getProjects: (): Promise<IPCResult<Project[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST),
|
||||
|
||||
updateProjectSettings: (
|
||||
projectId: string,
|
||||
settings: Partial<ProjectSettings>
|
||||
): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_UPDATE_SETTINGS, projectId, settings),
|
||||
|
||||
initializeProject: (projectId: string): Promise<IPCResult<InitializationResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_INITIALIZE, projectId),
|
||||
|
||||
updateProjectAutoBuild: (projectId: string): Promise<IPCResult<InitializationResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_UPDATE_AUTOBUILD, projectId),
|
||||
|
||||
checkProjectVersion: (projectId: string): Promise<IPCResult<AutoBuildVersionInfo>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CHECK_VERSION, projectId),
|
||||
|
||||
// Context Operations
|
||||
getProjectContext: (projectId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_GET, projectId),
|
||||
|
||||
refreshProjectIndex: (projectId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_REFRESH_INDEX, projectId),
|
||||
|
||||
getMemoryStatus: (projectId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_MEMORY_STATUS, projectId),
|
||||
|
||||
searchMemories: (projectId: string, query: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_SEARCH_MEMORIES, projectId, query),
|
||||
|
||||
getRecentMemories: (projectId: string, limit?: number) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_GET_MEMORIES, projectId, limit),
|
||||
|
||||
// Environment Configuration
|
||||
getProjectEnv: (projectId: string): Promise<IPCResult<ProjectEnvConfig>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ENV_GET, projectId),
|
||||
|
||||
updateProjectEnv: (projectId: string, config: Partial<ProjectEnvConfig>): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ENV_UPDATE, projectId, config),
|
||||
|
||||
checkClaudeAuth: (projectId: string): Promise<IPCResult<ClaudeAuthResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH, projectId),
|
||||
|
||||
invokeClaudeSetup: (projectId: string): Promise<IPCResult<ClaudeAuthResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP, projectId),
|
||||
|
||||
// Dialog Operations
|
||||
selectDirectory: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.DIALOG_SELECT_DIRECTORY),
|
||||
|
||||
createProjectFolder: (
|
||||
location: string,
|
||||
name: string,
|
||||
initGit: boolean
|
||||
): Promise<IPCResult<import('../../shared/types').CreateProjectFolderResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.DIALOG_CREATE_PROJECT_FOLDER, location, name, initGit),
|
||||
|
||||
getDefaultProjectLocation: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.DIALOG_GET_DEFAULT_PROJECT_LOCATION)
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
AppSettings,
|
||||
IPCResult,
|
||||
SourceEnvConfig,
|
||||
SourceEnvCheckResult
|
||||
} from '../../shared/types';
|
||||
|
||||
export interface SettingsAPI {
|
||||
// App Settings
|
||||
getSettings: () => Promise<IPCResult<AppSettings>>;
|
||||
saveSettings: (settings: Partial<AppSettings>) => Promise<IPCResult>;
|
||||
|
||||
// App Info
|
||||
getAppVersion: () => Promise<string>;
|
||||
|
||||
// Auto-Build Source Environment
|
||||
getSourceEnv: () => Promise<IPCResult<SourceEnvConfig>>;
|
||||
updateSourceEnv: (config: { claudeOAuthToken?: string }) => Promise<IPCResult>;
|
||||
checkSourceToken: () => Promise<IPCResult<SourceEnvCheckResult>>;
|
||||
}
|
||||
|
||||
export const createSettingsAPI = (): SettingsAPI => ({
|
||||
// App Settings
|
||||
getSettings: (): Promise<IPCResult<AppSettings>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET),
|
||||
|
||||
saveSettings: (settings: Partial<AppSettings>): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SAVE, settings),
|
||||
|
||||
// App Info
|
||||
getAppVersion: (): Promise<string> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.APP_VERSION),
|
||||
|
||||
// Auto-Build Source Environment
|
||||
getSourceEnv: (): Promise<IPCResult<SourceEnvConfig>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET),
|
||||
|
||||
updateSourceEnv: (config: { claudeOAuthToken?: string }): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE, config),
|
||||
|
||||
checkSourceToken: (): Promise<IPCResult<SourceEnvCheckResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN)
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
Task,
|
||||
IPCResult,
|
||||
TaskStartOptions,
|
||||
TaskStatus,
|
||||
TaskRecoveryResult,
|
||||
ImplementationPlan,
|
||||
TaskMetadata,
|
||||
TaskLogs,
|
||||
TaskLogStreamChunk
|
||||
} from '../../shared/types';
|
||||
|
||||
export interface TaskAPI {
|
||||
// Task Operations
|
||||
getTasks: (projectId: string) => Promise<IPCResult<Task[]>>;
|
||||
createTask: (
|
||||
projectId: string,
|
||||
title: string,
|
||||
description: string,
|
||||
metadata?: TaskMetadata
|
||||
) => Promise<IPCResult<Task>>;
|
||||
deleteTask: (taskId: string) => Promise<IPCResult>;
|
||||
updateTask: (
|
||||
taskId: string,
|
||||
updates: { title?: string; description?: string }
|
||||
) => Promise<IPCResult<Task>>;
|
||||
startTask: (taskId: string, options?: TaskStartOptions) => void;
|
||||
stopTask: (taskId: string) => void;
|
||||
submitReview: (
|
||||
taskId: string,
|
||||
approved: boolean,
|
||||
feedback?: string
|
||||
) => Promise<IPCResult>;
|
||||
updateTaskStatus: (
|
||||
taskId: string,
|
||||
status: TaskStatus
|
||||
) => Promise<IPCResult>;
|
||||
recoverStuckTask: (
|
||||
taskId: string,
|
||||
options?: import('../../shared/types').TaskRecoveryOptions
|
||||
) => Promise<IPCResult<TaskRecoveryResult>>;
|
||||
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
|
||||
|
||||
// Workspace Management (for human review)
|
||||
getWorktreeStatus: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeStatus>>;
|
||||
getWorktreeDiff: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeDiff>>;
|
||||
mergeWorktree: (taskId: string, options?: { noCommit?: boolean }) => Promise<IPCResult<import('../../shared/types').WorktreeMergeResult>>;
|
||||
discardWorktree: (taskId: string) => Promise<IPCResult<import('../../shared/types').WorktreeDiscardResult>>;
|
||||
listWorktrees: (projectId: string) => Promise<IPCResult<import('../../shared/types').WorktreeListResult>>;
|
||||
archiveTasks: (projectId: string, taskIds: string[], version?: string) => Promise<IPCResult<boolean>>;
|
||||
unarchiveTasks: (projectId: string, taskIds: string[]) => Promise<IPCResult<boolean>>;
|
||||
|
||||
// Task Event Listeners
|
||||
onTaskProgress: (callback: (taskId: string, plan: ImplementationPlan) => void) => () => void;
|
||||
onTaskError: (callback: (taskId: string, error: string) => void) => () => void;
|
||||
onTaskLog: (callback: (taskId: string, log: string) => void) => () => void;
|
||||
onTaskStatusChange: (callback: (taskId: string, status: TaskStatus) => void) => () => void;
|
||||
onTaskExecutionProgress: (
|
||||
callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress) => void
|
||||
) => () => void;
|
||||
|
||||
// Task Phase Logs
|
||||
getTaskLogs: (projectId: string, specId: string) => Promise<IPCResult<TaskLogs | null>>;
|
||||
watchTaskLogs: (projectId: string, specId: string) => Promise<IPCResult>;
|
||||
unwatchTaskLogs: (specId: string) => Promise<IPCResult>;
|
||||
onTaskLogsChanged: (callback: (specId: string, logs: TaskLogs) => void) => () => void;
|
||||
onTaskLogsStream: (callback: (specId: string, chunk: TaskLogStreamChunk) => void) => () => void;
|
||||
}
|
||||
|
||||
export const createTaskAPI = (): TaskAPI => ({
|
||||
// Task Operations
|
||||
getTasks: (projectId: string): Promise<IPCResult<Task[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST, projectId),
|
||||
|
||||
createTask: (
|
||||
projectId: string,
|
||||
title: string,
|
||||
description: string,
|
||||
metadata?: TaskMetadata
|
||||
): Promise<IPCResult<Task>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_CREATE, projectId, title, description, metadata),
|
||||
|
||||
deleteTask: (taskId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_DELETE, taskId),
|
||||
|
||||
updateTask: (
|
||||
taskId: string,
|
||||
updates: { title?: string; description?: string }
|
||||
): Promise<IPCResult<Task>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_UPDATE, taskId, updates),
|
||||
|
||||
startTask: (taskId: string, options?: TaskStartOptions): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TASK_START, taskId, options),
|
||||
|
||||
stopTask: (taskId: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TASK_STOP, taskId),
|
||||
|
||||
submitReview: (
|
||||
taskId: string,
|
||||
approved: boolean,
|
||||
feedback?: string
|
||||
): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_REVIEW, taskId, approved, feedback),
|
||||
|
||||
updateTaskStatus: (
|
||||
taskId: string,
|
||||
status: TaskStatus
|
||||
): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_UPDATE_STATUS, taskId, status),
|
||||
|
||||
recoverStuckTask: (
|
||||
taskId: string,
|
||||
options?: import('../../shared/types').TaskRecoveryOptions
|
||||
): Promise<IPCResult<TaskRecoveryResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_RECOVER_STUCK, taskId, options),
|
||||
|
||||
checkTaskRunning: (taskId: string): Promise<IPCResult<boolean>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId),
|
||||
|
||||
// Workspace Management
|
||||
getWorktreeStatus: (taskId: string): Promise<IPCResult<import('../../shared/types').WorktreeStatus>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_STATUS, taskId),
|
||||
|
||||
getWorktreeDiff: (taskId: string): Promise<IPCResult<import('../../shared/types').WorktreeDiff>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DIFF, taskId),
|
||||
|
||||
mergeWorktree: (taskId: string, options?: { noCommit?: boolean }): Promise<IPCResult<import('../../shared/types').WorktreeMergeResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_MERGE, taskId, options),
|
||||
|
||||
discardWorktree: (taskId: string): Promise<IPCResult<import('../../shared/types').WorktreeDiscardResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DISCARD, taskId),
|
||||
|
||||
listWorktrees: (projectId: string): Promise<IPCResult<import('../../shared/types').WorktreeListResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST_WORKTREES, projectId),
|
||||
|
||||
archiveTasks: (projectId: string, taskIds: string[], version?: string): Promise<IPCResult<boolean>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_ARCHIVE, projectId, taskIds, version),
|
||||
|
||||
unarchiveTasks: (projectId: string, taskIds: string[]): Promise<IPCResult<boolean>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_UNARCHIVE, projectId, taskIds),
|
||||
|
||||
// Task Event Listeners
|
||||
onTaskProgress: (
|
||||
callback: (taskId: string, plan: ImplementationPlan) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
taskId: string,
|
||||
plan: ImplementationPlan
|
||||
): void => {
|
||||
callback(taskId, plan);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TASK_PROGRESS, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TASK_PROGRESS, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTaskError: (
|
||||
callback: (taskId: string, error: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
taskId: string,
|
||||
error: string
|
||||
): void => {
|
||||
callback(taskId, error);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TASK_ERROR, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TASK_ERROR, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTaskLog: (
|
||||
callback: (taskId: string, log: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
taskId: string,
|
||||
log: string
|
||||
): void => {
|
||||
callback(taskId, log);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TASK_LOG, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOG, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTaskStatusChange: (
|
||||
callback: (taskId: string, status: TaskStatus) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
taskId: string,
|
||||
status: TaskStatus
|
||||
): void => {
|
||||
callback(taskId, status);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TASK_STATUS_CHANGE, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TASK_STATUS_CHANGE, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTaskExecutionProgress: (
|
||||
callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
taskId: string,
|
||||
progress: import('../../shared/types').ExecutionProgress
|
||||
): void => {
|
||||
callback(taskId, progress);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler);
|
||||
};
|
||||
},
|
||||
|
||||
// Task Phase Logs
|
||||
getTaskLogs: (projectId: string, specId: string): Promise<IPCResult<TaskLogs | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LOGS_GET, projectId, specId),
|
||||
|
||||
watchTaskLogs: (projectId: string, specId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LOGS_WATCH, projectId, specId),
|
||||
|
||||
unwatchTaskLogs: (specId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LOGS_UNWATCH, specId),
|
||||
|
||||
onTaskLogsChanged: (
|
||||
callback: (specId: string, logs: TaskLogs) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
specId: string,
|
||||
logs: TaskLogs
|
||||
): void => {
|
||||
callback(specId, logs);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TASK_LOGS_CHANGED, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOGS_CHANGED, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTaskLogsStream: (
|
||||
callback: (specId: string, chunk: TaskLogStreamChunk) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
specId: string,
|
||||
chunk: TaskLogStreamChunk
|
||||
): void => {
|
||||
callback(specId, chunk);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TASK_LOGS_STREAM, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOGS_STREAM, handler);
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
IPCResult,
|
||||
TerminalCreateOptions,
|
||||
RateLimitInfo,
|
||||
ClaudeProfile,
|
||||
ClaudeProfileSettings
|
||||
} from '../../shared/types';
|
||||
|
||||
export interface TerminalAPI {
|
||||
// Terminal Operations
|
||||
createTerminal: (options: TerminalCreateOptions) => Promise<IPCResult>;
|
||||
destroyTerminal: (id: string) => Promise<IPCResult>;
|
||||
sendTerminalInput: (id: string, data: string) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => void;
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string) => void;
|
||||
|
||||
// Terminal Session Management
|
||||
getTerminalSessions: (projectPath: string) => Promise<IPCResult<import('../../shared/types').TerminalSession[]>>;
|
||||
restoreTerminalSession: (
|
||||
session: import('../../shared/types').TerminalSession,
|
||||
cols?: number,
|
||||
rows?: number
|
||||
) => Promise<IPCResult<import('../../shared/types').TerminalRestoreResult>>;
|
||||
clearTerminalSessions: (projectPath: string) => Promise<IPCResult>;
|
||||
resumeClaudeInTerminal: (id: string, sessionId?: string) => void;
|
||||
getTerminalSessionDates: (projectPath?: string) => Promise<IPCResult<import('../../shared/types').SessionDateInfo[]>>;
|
||||
getTerminalSessionsForDate: (
|
||||
date: string,
|
||||
projectPath: string
|
||||
) => Promise<IPCResult<import('../../shared/types').TerminalSession[]>>;
|
||||
restoreTerminalSessionsFromDate: (
|
||||
date: string,
|
||||
projectPath: string,
|
||||
cols?: number,
|
||||
rows?: number
|
||||
) => Promise<IPCResult<import('../../shared/types').SessionDateRestoreResult>>;
|
||||
|
||||
// Terminal Event Listeners
|
||||
onTerminalOutput: (callback: (id: string, data: string) => void) => () => void;
|
||||
onTerminalExit: (callback: (id: string, exitCode: number) => void) => () => void;
|
||||
onTerminalTitleChange: (callback: (id: string, title: string) => void) => () => void;
|
||||
onTerminalClaudeSession: (callback: (id: string, sessionId: string) => void) => () => void;
|
||||
onTerminalRateLimit: (callback: (info: RateLimitInfo) => void) => () => void;
|
||||
onTerminalOAuthToken: (
|
||||
callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string }) => void
|
||||
) => () => void;
|
||||
|
||||
// Claude Profile Management
|
||||
getClaudeProfiles: () => Promise<IPCResult<ClaudeProfileSettings>>;
|
||||
saveClaudeProfile: (profile: ClaudeProfile) => Promise<IPCResult<ClaudeProfile>>;
|
||||
deleteClaudeProfile: (profileId: string) => Promise<IPCResult>;
|
||||
renameClaudeProfile: (profileId: string, newName: string) => Promise<IPCResult>;
|
||||
setActiveClaudeProfile: (profileId: string) => Promise<IPCResult>;
|
||||
switchClaudeProfile: (terminalId: string, profileId: string) => Promise<IPCResult>;
|
||||
initializeClaudeProfile: (profileId: string) => Promise<IPCResult>;
|
||||
setClaudeProfileToken: (profileId: string, token: string, email?: string) => Promise<IPCResult>;
|
||||
getAutoSwitchSettings: () => Promise<IPCResult<import('../../shared/types').ClaudeAutoSwitchSettings>>;
|
||||
updateAutoSwitchSettings: (settings: Partial<import('../../shared/types').ClaudeAutoSwitchSettings>) => Promise<IPCResult>;
|
||||
fetchClaudeUsage: (terminalId: string) => Promise<IPCResult>;
|
||||
getBestAvailableProfile: (excludeProfileId?: string) => Promise<IPCResult<import('../../shared/types').ClaudeProfile | null>>;
|
||||
onSDKRateLimit: (callback: (info: import('../../shared/types').SDKRateLimitInfo) => void) => () => void;
|
||||
retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest) => Promise<IPCResult>;
|
||||
}
|
||||
|
||||
export const createTerminalAPI = (): TerminalAPI => ({
|
||||
// Terminal Operations
|
||||
createTerminal: (options: TerminalCreateOptions): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CREATE, options),
|
||||
|
||||
destroyTerminal: (id: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_DESTROY, id),
|
||||
|
||||
sendTerminalInput: (id: string, data: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INPUT, id, data),
|
||||
|
||||
resizeTerminal: (id: string, cols: number, rows: number): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESIZE, id, cols, rows),
|
||||
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, id, cwd),
|
||||
|
||||
// Terminal Session Management
|
||||
getTerminalSessions: (projectPath: string): Promise<IPCResult<import('../../shared/types').TerminalSession[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS, projectPath),
|
||||
|
||||
restoreTerminalSession: (
|
||||
session: import('../../shared/types').TerminalSession,
|
||||
cols?: number,
|
||||
rows?: number
|
||||
): Promise<IPCResult<import('../../shared/types').TerminalRestoreResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESTORE_SESSION, session, cols, rows),
|
||||
|
||||
clearTerminalSessions: (projectPath: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, projectPath),
|
||||
|
||||
resumeClaudeInTerminal: (id: string, sessionId?: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, id, sessionId),
|
||||
|
||||
getTerminalSessionDates: (projectPath?: string): Promise<IPCResult<import('../../shared/types').SessionDateInfo[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSION_DATES, projectPath),
|
||||
|
||||
getTerminalSessionsForDate: (
|
||||
date: string,
|
||||
projectPath: string
|
||||
): Promise<IPCResult<import('../../shared/types').TerminalSession[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS_FOR_DATE, date, projectPath),
|
||||
|
||||
restoreTerminalSessionsFromDate: (
|
||||
date: string,
|
||||
projectPath: string,
|
||||
cols?: number,
|
||||
rows?: number
|
||||
): Promise<IPCResult<import('../../shared/types').SessionDateRestoreResult>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESTORE_FROM_DATE, date, projectPath, cols, rows),
|
||||
|
||||
// Terminal Event Listeners
|
||||
onTerminalOutput: (
|
||||
callback: (id: string, data: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
id: string,
|
||||
data: string
|
||||
): void => {
|
||||
callback(id, data);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TERMINAL_OUTPUT, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_OUTPUT, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTerminalExit: (
|
||||
callback: (id: string, exitCode: number) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
id: string,
|
||||
exitCode: number
|
||||
): void => {
|
||||
callback(id, exitCode);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TERMINAL_EXIT, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_EXIT, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTerminalTitleChange: (
|
||||
callback: (id: string, title: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
id: string,
|
||||
title: string
|
||||
): void => {
|
||||
callback(id, title);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTerminalClaudeSession: (
|
||||
callback: (id: string, sessionId: string) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
id: string,
|
||||
sessionId: string
|
||||
): void => {
|
||||
callback(id, sessionId);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTerminalRateLimit: (
|
||||
callback: (info: RateLimitInfo) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
info: RateLimitInfo
|
||||
): void => {
|
||||
callback(info);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TERMINAL_RATE_LIMIT, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_RATE_LIMIT, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTerminalOAuthToken: (
|
||||
callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string }) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string }
|
||||
): void => {
|
||||
callback(info);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, handler);
|
||||
};
|
||||
},
|
||||
|
||||
// Claude Profile Management
|
||||
getClaudeProfiles: (): Promise<IPCResult<ClaudeProfileSettings>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILES_GET),
|
||||
|
||||
saveClaudeProfile: (profile: ClaudeProfile): Promise<IPCResult<ClaudeProfile>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SAVE, profile),
|
||||
|
||||
deleteClaudeProfile: (profileId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_DELETE, profileId),
|
||||
|
||||
renameClaudeProfile: (profileId: string, newName: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_RENAME, profileId, newName),
|
||||
|
||||
setActiveClaudeProfile: (profileId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE, profileId),
|
||||
|
||||
switchClaudeProfile: (terminalId: string, profileId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SWITCH, terminalId, profileId),
|
||||
|
||||
initializeClaudeProfile: (profileId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE, profileId),
|
||||
|
||||
setClaudeProfileToken: (profileId: string, token: string, email?: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN, profileId, token, email),
|
||||
|
||||
getAutoSwitchSettings: (): Promise<IPCResult<import('../../shared/types').ClaudeAutoSwitchSettings>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS),
|
||||
|
||||
updateAutoSwitchSettings: (settings: Partial<import('../../shared/types').ClaudeAutoSwitchSettings>): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH, settings),
|
||||
|
||||
fetchClaudeUsage: (terminalId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE, terminalId),
|
||||
|
||||
getBestAvailableProfile: (excludeProfileId?: string): Promise<IPCResult<import('../../shared/types').ClaudeProfile | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_GET_BEST_PROFILE, excludeProfileId),
|
||||
|
||||
onSDKRateLimit: (
|
||||
callback: (info: import('../../shared/types').SDKRateLimitInfo) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
info: import('../../shared/types').SDKRateLimitInfo
|
||||
): void => {
|
||||
callback(info);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, handler);
|
||||
};
|
||||
},
|
||||
|
||||
retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, request)
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ import { KanbanBoard } from './components/KanbanBoard';
|
||||
import { TaskDetailPanel } from './components/TaskDetailPanel';
|
||||
import { TaskCreationWizard } from './components/TaskCreationWizard';
|
||||
import { AppSettingsDialog } from './components/AppSettings';
|
||||
import { ProjectSettings } from './components/ProjectSettings';
|
||||
import { ProjectSettings } from './components/project-settings';
|
||||
import { TerminalGrid } from './components/TerminalGrid';
|
||||
import { Roadmap } from './components/Roadmap';
|
||||
import { Context } from './components/Context';
|
||||
@@ -70,6 +70,15 @@ export function App() {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Check if selected project needs initialization (e.g., .auto-claude folder was deleted)
|
||||
useEffect(() => {
|
||||
if (selectedProject && !selectedProject.autoBuildPath && !showInitDialog) {
|
||||
// Project exists but isn't initialized - show init dialog
|
||||
setPendingProject(selectedProject);
|
||||
setShowInitDialog(true);
|
||||
}
|
||||
}, [selectedProject, showInitDialog]);
|
||||
|
||||
// Load tasks when project changes
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) {
|
||||
@@ -247,7 +256,10 @@ export function App() {
|
||||
)}
|
||||
{/* TerminalGrid is always mounted but hidden when not active to preserve terminal state */}
|
||||
<div className={activeView === 'terminals' ? 'h-full' : 'hidden'}>
|
||||
<TerminalGrid projectPath={selectedProject?.path} />
|
||||
<TerminalGrid
|
||||
projectPath={selectedProject?.path}
|
||||
onNewTaskClick={() => setIsNewTaskDialogOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
{activeView === 'roadmap' && selectedProjectId && (
|
||||
<Roadmap projectId={selectedProjectId} />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useTaskStore, persistUpdateTask } from '../stores/task-store';
|
||||
import type { Task, TaskStatus } from '../../shared/types';
|
||||
import type { Task, TaskStatus, ElectronAPI } from '../../shared/types';
|
||||
|
||||
// Helper to create test tasks
|
||||
function createTestTask(overrides: Partial<Task> = {}): Task {
|
||||
@@ -23,15 +23,11 @@ function createTestTask(overrides: Partial<Task> = {}): Task {
|
||||
};
|
||||
}
|
||||
|
||||
// Mock the window.electronAPI
|
||||
const mockUpdateTask = vi.fn();
|
||||
// Import browser mock to get full ElectronAPI structure
|
||||
import '../lib/browser-mock';
|
||||
|
||||
// We need to mock at module level before imports
|
||||
vi.mock('../../preload/index', () => ({
|
||||
electronAPI: {
|
||||
updateTask: mockUpdateTask
|
||||
}
|
||||
}));
|
||||
// Mock the window.electronAPI.updateTask specifically
|
||||
const mockUpdateTask = vi.fn();
|
||||
|
||||
// Override window.electronAPI for these tests
|
||||
const originalWindow = global.window;
|
||||
@@ -46,13 +42,10 @@ describe('TaskEditDialog Logic', () => {
|
||||
error: null
|
||||
});
|
||||
|
||||
// Setup mock window.electronAPI
|
||||
(global as typeof globalThis & { window: { electronAPI: { updateTask: typeof mockUpdateTask } } }).window = {
|
||||
...originalWindow,
|
||||
electronAPI: {
|
||||
updateTask: mockUpdateTask
|
||||
}
|
||||
} as typeof window;
|
||||
// Override just the updateTask method on the existing electronAPI
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.updateTask = mockUpdateTask;
|
||||
}
|
||||
|
||||
// Clear mock calls
|
||||
mockUpdateTask.mockReset();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,19 @@ import {
|
||||
GitBranch,
|
||||
Clock,
|
||||
Lightbulb,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Key,
|
||||
Route,
|
||||
Shield,
|
||||
Zap,
|
||||
FileText,
|
||||
Activity,
|
||||
Lock,
|
||||
Mail,
|
||||
CreditCard,
|
||||
HardDrive
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
@@ -30,6 +42,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from './ui/tooltip';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from './ui/collapsible';
|
||||
import { cn } from '../lib/utils';
|
||||
import {
|
||||
useContextStore,
|
||||
@@ -441,6 +458,11 @@ export function Context({ projectId }: ContextProps) {
|
||||
function ServiceCard({ name, service }: { name: string; service: ServiceInfo }) {
|
||||
const Icon = serviceTypeIcons[service.type || 'unknown'];
|
||||
const colorClass = serviceTypeColors[service.type || 'unknown'];
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
@@ -478,6 +500,11 @@ function ServiceCard({ name, service }: { name: string; service: ServiceInfo })
|
||||
{service.package_manager}
|
||||
</Badge>
|
||||
)}
|
||||
{service.build_tool && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{service.build_tool}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
@@ -506,8 +533,238 @@ function ServiceCard({ name, service }: { name: string; service: ServiceInfo })
|
||||
<span>Port: {service.default_port}</span>
|
||||
</div>
|
||||
)}
|
||||
{service.styling && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Code className="h-3 w-3 shrink-0" />
|
||||
<span>Styling: {service.styling}</span>
|
||||
</div>
|
||||
)}
|
||||
{service.state_management && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Package className="h-3 w-3 shrink-0" />
|
||||
<span>State: {service.state_management}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
{service.environment && service.environment.detected_count > 0 && (
|
||||
<Collapsible
|
||||
open={expandedSections['env']}
|
||||
onOpenChange={() => toggleSection('env')}
|
||||
className="border-t border-border pt-3"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-xs font-medium hover:text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-3 w-3" />
|
||||
Environment Variables ({service.environment.detected_count})
|
||||
</div>
|
||||
{expandedSections['env'] ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-1.5">
|
||||
{Object.entries(service.environment.variables).slice(0, 10).map(([key, envVar]) => (
|
||||
<div key={key} className="flex items-start gap-2 text-xs">
|
||||
<Badge variant={envVar.sensitive ? "destructive" : "outline"} className="text-xs shrink-0">
|
||||
{envVar.type}
|
||||
</Badge>
|
||||
<code className="flex-1 font-mono text-muted-foreground truncate">{key}</code>
|
||||
{envVar.required && <span className="text-orange-500 shrink-0">*</span>}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* API Routes */}
|
||||
{service.api && service.api.total_routes > 0 && (
|
||||
<Collapsible
|
||||
open={expandedSections['api']}
|
||||
onOpenChange={() => toggleSection('api')}
|
||||
className="border-t border-border pt-3"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-xs font-medium hover:text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Route className="h-3 w-3" />
|
||||
API Routes ({service.api.total_routes})
|
||||
</div>
|
||||
{expandedSections['api'] ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-1.5">
|
||||
{service.api.routes.slice(0, 10).map((route, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-xs">
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{route.methods.map(method => (
|
||||
<Badge key={method} variant="secondary" className="text-xs">
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<code className="flex-1 font-mono text-muted-foreground truncate">{route.path}</code>
|
||||
{route.requires_auth && <Lock className="h-3 w-3 text-orange-500 shrink-0" />}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Database Models */}
|
||||
{service.database && service.database.total_models > 0 && (
|
||||
<Collapsible
|
||||
open={expandedSections['db']}
|
||||
onOpenChange={() => toggleSection('db')}
|
||||
className="border-t border-border pt-3"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-xs font-medium hover:text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-3 w-3" />
|
||||
Database Models ({service.database.total_models})
|
||||
</div>
|
||||
{expandedSections['db'] ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-1.5">
|
||||
{service.database.model_names.slice(0, 10).map(modelName => {
|
||||
const model = service.database!.models[modelName];
|
||||
return (
|
||||
<div key={modelName} className="flex items-start gap-2 text-xs">
|
||||
<Badge variant="outline" className="text-xs shrink-0">{model.orm}</Badge>
|
||||
<code className="flex-1 font-mono text-muted-foreground truncate">{modelName}</code>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{Object.keys(model.fields).length} fields
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* External Services */}
|
||||
{service.services && Object.values(service.services).some(arr => arr && arr.length > 0) && (
|
||||
<Collapsible
|
||||
open={expandedSections['services']}
|
||||
onOpenChange={() => toggleSection('services')}
|
||||
className="border-t border-border pt-3"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-xs font-medium hover:text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-3 w-3" />
|
||||
External Services
|
||||
</div>
|
||||
{expandedSections['services'] ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
{service.services.databases && service.services.databases.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Databases</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{service.services.databases.map((db, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
<HardDrive className="h-3 w-3 mr-1" />
|
||||
{db.type || db.client}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{service.services.email && service.services.email.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Email</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{service.services.email.map((email, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
<Mail className="h-3 w-3 mr-1" />
|
||||
{email.provider || email.client}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{service.services.payments && service.services.payments.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Payments</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{service.services.payments.map((payment, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
<CreditCard className="h-3 w-3 mr-1" />
|
||||
{payment.provider || payment.client}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{service.services.cache && service.services.cache.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Cache</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{service.services.cache.map((cache, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
<Zap className="h-3 w-3 mr-1" />
|
||||
{cache.type || cache.client}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Monitoring */}
|
||||
{service.monitoring && (
|
||||
<Collapsible
|
||||
open={expandedSections['monitoring']}
|
||||
onOpenChange={() => toggleSection('monitoring')}
|
||||
className="border-t border-border pt-3"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-xs font-medium hover:text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-3 w-3" />
|
||||
Monitoring
|
||||
</div>
|
||||
{expandedSections['monitoring'] ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2 text-xs text-muted-foreground">
|
||||
{service.monitoring.metrics_endpoint && (
|
||||
<div>Metrics: <code className="text-xs">{service.monitoring.metrics_endpoint}</code> ({service.monitoring.metrics_type})</div>
|
||||
)}
|
||||
{service.monitoring.health_checks && service.monitoring.health_checks.length > 0 && (
|
||||
<div>Health: {service.monitoring.health_checks.join(', ')}</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Dependencies */}
|
||||
{service.dependencies && service.dependencies.length > 0 && (
|
||||
<Collapsible
|
||||
open={expandedSections['deps']}
|
||||
onOpenChange={() => toggleSection('deps')}
|
||||
className="border-t border-border pt-3"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-xs font-medium hover:text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3 w-3" />
|
||||
Dependencies ({service.dependencies.length})
|
||||
</div>
|
||||
{expandedSections['deps'] ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{service.dependencies.slice(0, 20).map(dep => (
|
||||
<Badge key={dep} variant="outline" className="text-xs font-mono">
|
||||
{dep}
|
||||
</Badge>
|
||||
))}
|
||||
{service.dependencies.length > 20 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{service.dependencies.length - 20} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Key Directories */}
|
||||
{service.key_directories && Object.keys(service.key_directories).length > 0 && (
|
||||
<div className="pt-2 border-t border-border">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -196,6 +196,7 @@ function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick }: Dro
|
||||
}
|
||||
|
||||
export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardProps) {
|
||||
console.log('[KanbanBoard] Received', tasks.length, 'tasks');
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [overColumnId, setOverColumnId] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
@@ -977,6 +977,7 @@ export function ProjectSettings({ project, open, onOpenChange }: ProjectSettings
|
||||
graphitiProviderConfig: {
|
||||
...envConfig.graphitiProviderConfig,
|
||||
llmProvider: value as 'openai' | 'anthropic' | 'google' | 'groq',
|
||||
embeddingProvider: envConfig.graphitiProviderConfig?.embeddingProvider || 'openai',
|
||||
}
|
||||
})}
|
||||
>
|
||||
@@ -1003,6 +1004,7 @@ export function ProjectSettings({ project, open, onOpenChange }: ProjectSettings
|
||||
onValueChange={(value) => updateEnvConfig({
|
||||
graphitiProviderConfig: {
|
||||
...envConfig.graphitiProviderConfig,
|
||||
llmProvider: envConfig.graphitiProviderConfig?.llmProvider || 'openai',
|
||||
embeddingProvider: value as 'openai' | 'voyage' | 'google' | 'huggingface',
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -250,7 +250,7 @@ export function RateLimitModal() {
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{profile.name}
|
||||
{profile.id === rateLimitInfo?.suggestedProfileId && (
|
||||
<Star className="h-3 w-3 text-yellow-500" title="Recommended" />
|
||||
<Star className="h-3 w-3 text-yellow-500" aria-label="Recommended" />
|
||||
)}
|
||||
</span>
|
||||
{selectedProfileId === profile.id && (
|
||||
|
||||
@@ -289,7 +289,7 @@ export function SDKRateLimitModal() {
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{profile.name}
|
||||
{profile.id === sdkRateLimitInfo.suggestedProfile?.id && (
|
||||
<Star className="h-3 w-3 text-yellow-500" title="Recommended" />
|
||||
<Star className="h-3 w-3 text-yellow-500" aria-label="Recommended" />
|
||||
)}
|
||||
</span>
|
||||
{selectedProfileId === profile.id && (
|
||||
|
||||
@@ -57,7 +57,7 @@ import { AddProjectModal } from './AddProjectModal';
|
||||
import { RateLimitIndicator } from './RateLimitIndicator';
|
||||
import type { Project, AutoBuildVersionInfo } from '../../shared/types';
|
||||
|
||||
export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'changelog' | 'insights' | 'worktrees';
|
||||
export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools';
|
||||
|
||||
interface SidebarProps {
|
||||
onSettingsClick: () => void;
|
||||
|
||||
@@ -124,6 +124,8 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
return { label: 'Has Errors', variant: 'destructive' };
|
||||
case 'qa_rejected':
|
||||
return { label: 'QA Issues', variant: 'warning' };
|
||||
case 'plan_review':
|
||||
return { label: 'Approve Plan', variant: 'warning' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { X, Sparkles, TerminalSquare, ListTodo, FileDown, ChevronDown, Circle, Loader2, CheckCircle2, AlertCircle, Clock, Code2, Search, Wrench, Pencil } from 'lucide-react';
|
||||
import { X, Sparkles, TerminalSquare, ListTodo, FileDown, ChevronDown, Circle, Loader2, CheckCircle2, AlertCircle, Clock, Code2, Search, Wrench, Pencil, Plus } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useTerminalStore, type TerminalStatus } from '../stores/terminal-store';
|
||||
@@ -38,6 +38,7 @@ interface TerminalProps {
|
||||
onClose: () => void;
|
||||
onActivate: () => void;
|
||||
tasks?: Task[]; // Tasks for task selection dropdown
|
||||
onNewTaskClick?: () => void; // Callback to open task creation dialog
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<TerminalStatus, string> = {
|
||||
@@ -58,7 +59,7 @@ const PHASE_CONFIG: Record<ExecutionPhase, { label: string; color: string; icon:
|
||||
failed: { label: 'Failed', color: 'bg-destructive/20 text-destructive', icon: AlertCircle },
|
||||
};
|
||||
|
||||
export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate, tasks = [] }: TerminalProps) {
|
||||
export function Terminal({ id, cwd, projectPath, isActive, onClose, onActivate, tasks = [], onNewTaskClick }: TerminalProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
@@ -592,28 +593,75 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
/* Show task selector when no task is selected */
|
||||
backlogTasks.length > 0 && (
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={handleTaskSelect}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-6 w-auto min-w-[120px] max-w-[160px] text-[10px] px-2 py-0 border-border/50 bg-card/50"
|
||||
/* Show task selector when no task is selected - always visible */
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1.5 h-6 px-2 rounded text-[10px] font-medium transition-colors border border-border/50 bg-card/50 hover:bg-card hover:border-border text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ListTodo className="h-3 w-3 mr-1 text-muted-foreground" />
|
||||
<SelectValue placeholder="Select task..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{backlogTasks.map((task) => (
|
||||
<SelectItem key={task.id} value={task.id} className="text-xs">
|
||||
<span className="truncate max-w-[200px]">{task.title}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
<ListTodo className="h-3 w-3" />
|
||||
<span>Select task...</span>
|
||||
<ChevronDown className="h-2.5 w-2.5 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{backlogTasks.length > 0 ? (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
Available tasks
|
||||
</div>
|
||||
{backlogTasks.slice(0, 8).map((task) => (
|
||||
<DropdownMenuItem
|
||||
key={task.id}
|
||||
onClick={() => handleTaskSelect(task.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
<ListTodo className="h-3 w-3 mr-2 text-muted-foreground" />
|
||||
<span className="truncate">{task.title}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{onNewTaskClick && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewTaskClick();
|
||||
}}
|
||||
className="text-xs text-primary"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-2" />
|
||||
Add new task
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
No tasks available
|
||||
</div>
|
||||
{onNewTaskClick ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewTaskClick();
|
||||
}}
|
||||
className="text-xs text-primary"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-2" />
|
||||
Add new task
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground italic">
|
||||
Create tasks in the Kanban board
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -32,9 +32,10 @@ import type { SessionDateInfo } from '../../shared/types';
|
||||
|
||||
interface TerminalGridProps {
|
||||
projectPath?: string;
|
||||
onNewTaskClick?: () => void;
|
||||
}
|
||||
|
||||
export function TerminalGrid({ projectPath }: TerminalGridProps) {
|
||||
export function TerminalGrid({ projectPath, onNewTaskClick }: TerminalGridProps) {
|
||||
const terminals = useTerminalStore((state) => state.terminals);
|
||||
const activeTerminalId = useTerminalStore((state) => state.activeTerminalId);
|
||||
const addTerminal = useTerminalStore((state) => state.addTerminal);
|
||||
@@ -382,6 +383,7 @@ export function TerminalGrid({ projectPath }: TerminalGridProps) {
|
||||
onClose={() => handleCloseTerminal(terminal.id)}
|
||||
onActivate={() => setActiveTerminal(terminal.id)}
|
||||
tasks={tasks}
|
||||
onNewTaskClick={onNewTaskClick}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { FileText } from 'lucide-react';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { ChangelogHeader } from './ChangelogHeader';
|
||||
import { ChangelogFilters } from './ChangelogFilters';
|
||||
import { ChangelogList } from './ChangelogList';
|
||||
import { Step2ConfigureGenerate, Step3ReleaseArchive } from './ChangelogDetails';
|
||||
import { useChangelog } from './hooks/useChangelog';
|
||||
|
||||
export function Changelog() {
|
||||
const {
|
||||
// State
|
||||
selectedProjectId,
|
||||
doneTasks,
|
||||
selectedTaskIds,
|
||||
existingChangelog,
|
||||
sourceMode,
|
||||
branches,
|
||||
tags,
|
||||
currentBranch,
|
||||
defaultBranch,
|
||||
previewCommits,
|
||||
isLoadingGitData,
|
||||
isLoadingCommits,
|
||||
gitHistoryType,
|
||||
gitHistoryCount,
|
||||
gitHistorySinceDate,
|
||||
gitHistoryFromTag,
|
||||
gitHistoryToTag,
|
||||
gitHistorySinceVersion,
|
||||
includeMergeCommits,
|
||||
baseBranch,
|
||||
compareBranch,
|
||||
version,
|
||||
date,
|
||||
format,
|
||||
audience,
|
||||
customInstructions,
|
||||
generationProgress,
|
||||
generatedChangelog,
|
||||
isGenerating,
|
||||
error,
|
||||
step,
|
||||
showAdvanced,
|
||||
saveSuccess,
|
||||
copySuccess,
|
||||
versionReason,
|
||||
canGenerate,
|
||||
canSave,
|
||||
canContinue,
|
||||
// Actions
|
||||
toggleTaskSelection,
|
||||
selectAllTasks,
|
||||
deselectAllTasks,
|
||||
setSourceMode,
|
||||
setGitHistoryType,
|
||||
setGitHistoryCount,
|
||||
setGitHistorySinceDate,
|
||||
setGitHistoryFromTag,
|
||||
setGitHistoryToTag,
|
||||
setGitHistorySinceVersion,
|
||||
setIncludeMergeCommits,
|
||||
setBaseBranch,
|
||||
setCompareBranch,
|
||||
setVersion,
|
||||
setDate,
|
||||
setFormat,
|
||||
setAudience,
|
||||
setCustomInstructions,
|
||||
updateGeneratedChangelog,
|
||||
setShowAdvanced,
|
||||
handleLoadCommitsPreview,
|
||||
handleGenerate,
|
||||
handleSave,
|
||||
handleCopy,
|
||||
handleContinue,
|
||||
handleBack,
|
||||
handleDone,
|
||||
handleRefresh
|
||||
} = useChangelog();
|
||||
|
||||
if (!selectedProjectId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileText className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium">No Project Selected</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Select a project from the sidebar to generate changelogs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<ChangelogHeader step={step} onRefresh={handleRefresh} />
|
||||
|
||||
{/* Content */}
|
||||
{step === 1 && (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<ChangelogFilters
|
||||
sourceMode={sourceMode}
|
||||
onSourceModeChange={setSourceMode}
|
||||
doneTasksCount={doneTasks.length}
|
||||
branches={branches}
|
||||
tags={tags}
|
||||
defaultBranch={defaultBranch}
|
||||
isLoadingGitData={isLoadingGitData}
|
||||
isLoadingCommits={isLoadingCommits}
|
||||
gitHistoryType={gitHistoryType}
|
||||
gitHistoryCount={gitHistoryCount}
|
||||
gitHistorySinceDate={gitHistorySinceDate}
|
||||
gitHistoryFromTag={gitHistoryFromTag}
|
||||
gitHistoryToTag={gitHistoryToTag}
|
||||
gitHistorySinceVersion={gitHistorySinceVersion}
|
||||
includeMergeCommits={includeMergeCommits}
|
||||
onGitHistoryTypeChange={setGitHistoryType}
|
||||
onGitHistoryCountChange={setGitHistoryCount}
|
||||
onGitHistorySinceDateChange={setGitHistorySinceDate}
|
||||
onGitHistoryFromTagChange={setGitHistoryFromTag}
|
||||
onGitHistoryToTagChange={setGitHistoryToTag}
|
||||
onGitHistorySinceVersionChange={setGitHistorySinceVersion}
|
||||
onIncludeMergeCommitsChange={setIncludeMergeCommits}
|
||||
baseBranch={baseBranch}
|
||||
compareBranch={compareBranch}
|
||||
onBaseBranchChange={setBaseBranch}
|
||||
onCompareBranchChange={setCompareBranch}
|
||||
onLoadCommitsPreview={handleLoadCommitsPreview}
|
||||
/>
|
||||
<ChangelogList
|
||||
sourceMode={sourceMode}
|
||||
doneTasks={doneTasks}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
onToggleTask={toggleTaskSelection}
|
||||
onSelectAll={selectAllTasks}
|
||||
onDeselectAll={deselectAllTasks}
|
||||
previewCommits={previewCommits}
|
||||
isLoadingCommits={isLoadingCommits}
|
||||
onContinue={handleContinue}
|
||||
canContinue={canContinue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Step2ConfigureGenerate
|
||||
sourceMode={sourceMode}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
doneTasks={doneTasks}
|
||||
previewCommits={previewCommits}
|
||||
existingChangelog={existingChangelog}
|
||||
version={version}
|
||||
versionReason={versionReason}
|
||||
date={date}
|
||||
format={format}
|
||||
audience={audience}
|
||||
customInstructions={customInstructions}
|
||||
generationProgress={generationProgress}
|
||||
generatedChangelog={generatedChangelog}
|
||||
isGenerating={isGenerating}
|
||||
error={error}
|
||||
showAdvanced={showAdvanced}
|
||||
saveSuccess={saveSuccess}
|
||||
copySuccess={copySuccess}
|
||||
canGenerate={canGenerate}
|
||||
canSave={canSave}
|
||||
onBack={handleBack}
|
||||
onVersionChange={setVersion}
|
||||
onDateChange={setDate}
|
||||
onFormatChange={setFormat}
|
||||
onAudienceChange={setAudience}
|
||||
onCustomInstructionsChange={setCustomInstructions}
|
||||
onShowAdvancedChange={setShowAdvanced}
|
||||
onGenerate={handleGenerate}
|
||||
onSave={handleSave}
|
||||
onCopy={handleCopy}
|
||||
onChangelogEdit={updateGeneratedChangelog}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && selectedProjectId && (
|
||||
<Step3ReleaseArchive
|
||||
projectId={selectedProjectId}
|
||||
version={version}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
doneTasks={doneTasks}
|
||||
generatedChangelog={generatedChangelog}
|
||||
onDone={handleDone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import { useState } from 'react';
|
||||
import { ArrowLeft, FileText, GitCommit, Sparkles, RefreshCw, AlertCircle, ChevronUp, ChevronDown, Copy, Save, CheckCircle, PartyPopper, Github, Archive, ExternalLink, Check } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Progress } from '../ui/progress';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
CHANGELOG_FORMAT_LABELS,
|
||||
CHANGELOG_FORMAT_DESCRIPTIONS,
|
||||
CHANGELOG_AUDIENCE_LABELS,
|
||||
CHANGELOG_AUDIENCE_DESCRIPTIONS,
|
||||
CHANGELOG_STAGE_LABELS
|
||||
} from '../../../shared/constants';
|
||||
import type {
|
||||
ChangelogFormat,
|
||||
ChangelogAudience,
|
||||
ChangelogTask,
|
||||
ChangelogSourceMode,
|
||||
GitCommit as GitCommitType
|
||||
} from '../../../shared/types';
|
||||
|
||||
interface Step2ConfigureGenerateProps {
|
||||
sourceMode: ChangelogSourceMode;
|
||||
selectedTaskIds: string[];
|
||||
doneTasks: ChangelogTask[];
|
||||
previewCommits: GitCommitType[];
|
||||
existingChangelog: { lastVersion?: string } | null;
|
||||
version: string;
|
||||
versionReason: string | null;
|
||||
date: string;
|
||||
format: ChangelogFormat;
|
||||
audience: ChangelogAudience;
|
||||
customInstructions: string;
|
||||
generationProgress: { stage: string; progress: number; message?: string; error?: string } | null;
|
||||
generatedChangelog: string;
|
||||
isGenerating: boolean;
|
||||
error: string | null;
|
||||
showAdvanced: boolean;
|
||||
saveSuccess: boolean;
|
||||
copySuccess: boolean;
|
||||
canGenerate: boolean;
|
||||
canSave: boolean;
|
||||
onBack: () => void;
|
||||
onVersionChange: (v: string) => void;
|
||||
onDateChange: (d: string) => void;
|
||||
onFormatChange: (f: ChangelogFormat) => void;
|
||||
onAudienceChange: (a: ChangelogAudience) => void;
|
||||
onCustomInstructionsChange: (i: string) => void;
|
||||
onShowAdvancedChange: (show: boolean) => void;
|
||||
onGenerate: () => void;
|
||||
onSave: () => void;
|
||||
onCopy: () => void;
|
||||
onChangelogEdit: (content: string) => void;
|
||||
}
|
||||
|
||||
export function Step2ConfigureGenerate({
|
||||
sourceMode,
|
||||
selectedTaskIds,
|
||||
doneTasks,
|
||||
previewCommits,
|
||||
existingChangelog,
|
||||
version,
|
||||
versionReason,
|
||||
date,
|
||||
format,
|
||||
audience,
|
||||
customInstructions,
|
||||
generationProgress,
|
||||
generatedChangelog,
|
||||
isGenerating,
|
||||
error,
|
||||
showAdvanced,
|
||||
saveSuccess,
|
||||
copySuccess,
|
||||
canGenerate,
|
||||
canSave,
|
||||
onBack,
|
||||
onVersionChange,
|
||||
onDateChange,
|
||||
onFormatChange,
|
||||
onAudienceChange,
|
||||
onCustomInstructionsChange,
|
||||
onShowAdvancedChange,
|
||||
onGenerate,
|
||||
onSave,
|
||||
onCopy,
|
||||
onChangelogEdit
|
||||
}: Step2ConfigureGenerateProps) {
|
||||
const selectedTasks = doneTasks.filter((t) => selectedTaskIds.includes(t.id));
|
||||
|
||||
// Get summary info based on source mode
|
||||
const getSummaryInfo = () => {
|
||||
switch (sourceMode) {
|
||||
case 'tasks':
|
||||
return {
|
||||
count: selectedTaskIds.length,
|
||||
label: 'task',
|
||||
details: selectedTasks.slice(0, 3).map((t) => t.title).join(', ') +
|
||||
(selectedTasks.length > 3 ? ` +${selectedTasks.length - 3} more` : '')
|
||||
};
|
||||
case 'git-history':
|
||||
case 'branch-diff':
|
||||
return {
|
||||
count: previewCommits.length,
|
||||
label: 'commit',
|
||||
details: previewCommits.slice(0, 3).map((c) => c.subject.substring(0, 40)).join(', ') +
|
||||
(previewCommits.length > 3 ? ` +${previewCommits.length - 3} more` : '')
|
||||
};
|
||||
default:
|
||||
return { count: 0, label: 'item', details: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const summaryInfo = getSummaryInfo();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Panel - Configuration */}
|
||||
<div className="w-80 flex-shrink-0 border-r border-border overflow-y-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Back button and task summary */}
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="-ml-2">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Selection
|
||||
</Button>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{sourceMode === 'tasks' ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<GitCommit className="h-4 w-4" />
|
||||
)}
|
||||
Including {summaryInfo.count} {summaryInfo.label}{summaryInfo.count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{summaryInfo.details}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version & Date */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Release Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="version">Version</Label>
|
||||
<Input
|
||||
id="version"
|
||||
value={version}
|
||||
onChange={(e) => onVersionChange(e.target.value)}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date">Date</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => onDateChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{(existingChangelog?.lastVersion || versionReason) && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{existingChangelog?.lastVersion && (
|
||||
<p>Previous: {existingChangelog.lastVersion}</p>
|
||||
)}
|
||||
{versionReason && (
|
||||
<p className="text-primary/70">
|
||||
{versionReason === 'breaking'
|
||||
? 'Major version bump (breaking changes detected)'
|
||||
: versionReason === 'feature'
|
||||
? 'Minor version bump (new features detected)'
|
||||
: 'Patch version bump (fixes/improvements)'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Format & Audience */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Output Style</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Format</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(value) => onFormatChange(value as ChangelogFormat)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(CHANGELOG_FORMAT_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div>
|
||||
<div>{label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{CHANGELOG_FORMAT_DESCRIPTIONS[value]}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Audience</Label>
|
||||
<Select
|
||||
value={audience}
|
||||
onValueChange={(value) => onAudienceChange(value as ChangelogAudience)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(CHANGELOG_AUDIENCE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div>
|
||||
<div>{label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{CHANGELOG_AUDIENCE_DESCRIPTIONS[value]}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={onShowAdvancedChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between">
|
||||
Advanced Options
|
||||
{showAdvanced ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instructions">Custom Instructions</Label>
|
||||
<Textarea
|
||||
id="instructions"
|
||||
value={customInstructions}
|
||||
onChange={(e) => onCustomInstructionsChange(e.target.value)}
|
||||
placeholder="Add any special instructions for the AI..."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Guide the AI on tone, specific details to include, etc.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={onGenerate}
|
||||
disabled={!canGenerate}
|
||||
size="lg"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Generate Changelog
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Progress */}
|
||||
{generationProgress && isGenerating && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{CHANGELOG_STAGE_LABELS[generationProgress.stage]}</span>
|
||||
<span>{generationProgress.progress}%</span>
|
||||
</div>
|
||||
<Progress value={generationProgress.progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
|
||||
<span className="text-destructive">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Preview */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Preview Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3">
|
||||
<h2 className="font-medium">Preview</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{copySuccess ? (
|
||||
<CheckCircle className="mr-2 h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{copySuccess ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{saveSuccess ? 'Saved!' : 'Save to CHANGELOG.md'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Prepend to CHANGELOG.md in project root
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
{generatedChangelog ? (
|
||||
<Textarea
|
||||
className="h-full w-full resize-none font-mono text-sm"
|
||||
value={generatedChangelog}
|
||||
onChange={(e) => onChangelogEdit(e.target.value)}
|
||||
placeholder="Generated changelog will appear here..."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileText className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Click "Generate Changelog" to create release notes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Step3ReleaseArchiveProps {
|
||||
projectId: string;
|
||||
version: string;
|
||||
selectedTaskIds: string[];
|
||||
doneTasks: ChangelogTask[];
|
||||
generatedChangelog: string;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function Step3ReleaseArchive({
|
||||
projectId,
|
||||
version,
|
||||
selectedTaskIds,
|
||||
doneTasks,
|
||||
generatedChangelog,
|
||||
onDone
|
||||
}: Step3ReleaseArchiveProps) {
|
||||
const [isCreatingRelease, setIsCreatingRelease] = useState(false);
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
const [releaseUrl, setReleaseUrl] = useState<string | null>(null);
|
||||
const [releaseError, setReleaseError] = useState<string | null>(null);
|
||||
const [archiveSuccess, setArchiveSuccess] = useState(false);
|
||||
const [archiveError, setArchiveError] = useState<string | null>(null);
|
||||
|
||||
const selectedTasks = doneTasks.filter((t) => selectedTaskIds.includes(t.id));
|
||||
const tag = version.startsWith('v') ? version : `v${version}`;
|
||||
|
||||
const handleCreateRelease = async () => {
|
||||
setIsCreatingRelease(true);
|
||||
setReleaseError(null);
|
||||
try {
|
||||
const result = await window.electronAPI.createGitHubRelease(
|
||||
projectId,
|
||||
version,
|
||||
generatedChangelog
|
||||
);
|
||||
if (result.success && result.data) {
|
||||
setReleaseUrl(result.data.url);
|
||||
} else {
|
||||
setReleaseError(result.error || 'Failed to create release');
|
||||
}
|
||||
} catch (err) {
|
||||
setReleaseError(err instanceof Error ? err.message : 'Failed to create release');
|
||||
} finally {
|
||||
setIsCreatingRelease(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
setIsArchiving(true);
|
||||
setArchiveError(null);
|
||||
try {
|
||||
const result = await window.electronAPI.archiveTasks(projectId, selectedTaskIds, version);
|
||||
if (result.success) {
|
||||
setArchiveSuccess(true);
|
||||
} else {
|
||||
setArchiveError(result.error || 'Failed to archive tasks');
|
||||
}
|
||||
} catch (err) {
|
||||
setArchiveError(err instanceof Error ? err.message : 'Failed to archive tasks');
|
||||
} finally {
|
||||
setIsArchiving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<div className="max-w-lg w-full space-y-8">
|
||||
{/* Success Message */}
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-success/10 mb-4">
|
||||
<PartyPopper className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Changelog Saved!</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Version {tag} has been added to CHANGELOG.md
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Cards */}
|
||||
<div className="space-y-4">
|
||||
{/* GitHub Release */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
<CardTitle className="text-base">Create GitHub Release</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{releaseUrl ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm">Release created successfully!</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => window.open(releaseUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Release on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a new release {tag} on GitHub with the changelog as release notes.
|
||||
</p>
|
||||
{releaseError && (
|
||||
<div className="flex items-start gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{releaseError}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCreateRelease}
|
||||
disabled={isCreatingRelease}
|
||||
>
|
||||
{isCreatingRelease ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating Release...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
Create Release {tag}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Archive Tasks */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Archive className="h-5 w-5" />
|
||||
<CardTitle className="text-base">Archive Completed Tasks</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{archiveSuccess ? (
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
{selectedTasks.length} task{selectedTasks.length !== 1 ? 's' : ''} archived!
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Archive {selectedTasks.length} task{selectedTasks.length !== 1 ? 's' : ''} to
|
||||
clean up your Kanban board. Archived tasks can be viewed using the "Show
|
||||
Archived" toggle.
|
||||
</p>
|
||||
{archiveError && (
|
||||
<div className="flex items-start gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{archiveError}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleArchive}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{isArchiving ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Archiving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive {selectedTasks.length} Task{selectedTasks.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Done Button */}
|
||||
<div className="pt-4">
|
||||
<Button className="w-full" size="lg" onClick={onDone}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { GitCommit } from 'lucide-react';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ChangelogTask, GitCommit as GitCommitType } from '../../../shared/types';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: ChangelogTask;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, isSelected, onToggle }: TaskCardProps) {
|
||||
const completedDate = new Date(task.completedAt).toLocaleDateString();
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg border p-4 cursor-pointer transition-all',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggle}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm leading-tight">{task.title}</h3>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{task.hasSpecs && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Has Specs
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommitCardProps {
|
||||
commit: GitCommitType;
|
||||
}
|
||||
|
||||
export function CommitCard({ commit }: CommitCardProps) {
|
||||
const commitDate = new Date(commit.date).toLocaleDateString();
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border p-3 bg-background">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
<GitCommit className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-tight line-clamp-2">{commit.subject}</p>
|
||||
<code className="text-xs text-muted-foreground font-mono shrink-0">{commit.hash}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span>{commit.author}</span>
|
||||
<span>{commitDate}</span>
|
||||
{commit.filesChanged !== undefined && (
|
||||
<span>
|
||||
{commit.filesChanged} file{commit.filesChanged !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { FileText, History, GitBranch, Tag, Calendar, RefreshCw, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import {
|
||||
CHANGELOG_SOURCE_MODE_LABELS,
|
||||
CHANGELOG_SOURCE_MODE_DESCRIPTIONS
|
||||
} from '../../../shared/constants';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ChangelogSourceMode, GitBranchInfo, GitTagInfo } from '../../../shared/types';
|
||||
|
||||
interface ChangelogFiltersProps {
|
||||
// Source mode
|
||||
sourceMode: ChangelogSourceMode;
|
||||
onSourceModeChange: (mode: ChangelogSourceMode) => void;
|
||||
// Task counts
|
||||
doneTasksCount: number;
|
||||
// Git data
|
||||
branches: GitBranchInfo[];
|
||||
tags: GitTagInfo[];
|
||||
defaultBranch: string;
|
||||
isLoadingGitData: boolean;
|
||||
isLoadingCommits: boolean;
|
||||
// Git history options
|
||||
gitHistoryType: 'recent' | 'since-date' | 'tag-range' | 'since-version';
|
||||
gitHistoryCount: number;
|
||||
gitHistorySinceDate: string;
|
||||
gitHistoryFromTag: string;
|
||||
gitHistoryToTag: string;
|
||||
gitHistorySinceVersion: string;
|
||||
includeMergeCommits: boolean;
|
||||
onGitHistoryTypeChange: (type: 'recent' | 'since-date' | 'tag-range' | 'since-version') => void;
|
||||
onGitHistoryCountChange: (count: number) => void;
|
||||
onGitHistorySinceDateChange: (date: string) => void;
|
||||
onGitHistoryFromTagChange: (tag: string) => void;
|
||||
onGitHistoryToTagChange: (tag: string) => void;
|
||||
onGitHistorySinceVersionChange: (version: string) => void;
|
||||
onIncludeMergeCommitsChange: (include: boolean) => void;
|
||||
// Branch diff options
|
||||
baseBranch: string;
|
||||
compareBranch: string;
|
||||
onBaseBranchChange: (branch: string) => void;
|
||||
onCompareBranchChange: (branch: string) => void;
|
||||
// Actions
|
||||
onLoadCommitsPreview: () => void;
|
||||
}
|
||||
|
||||
export function ChangelogFilters({
|
||||
sourceMode,
|
||||
onSourceModeChange,
|
||||
doneTasksCount,
|
||||
branches,
|
||||
tags,
|
||||
defaultBranch,
|
||||
isLoadingGitData,
|
||||
isLoadingCommits,
|
||||
gitHistoryType,
|
||||
gitHistoryCount,
|
||||
gitHistorySinceDate,
|
||||
gitHistoryFromTag,
|
||||
gitHistoryToTag,
|
||||
gitHistorySinceVersion,
|
||||
includeMergeCommits,
|
||||
onGitHistoryTypeChange,
|
||||
onGitHistoryCountChange,
|
||||
onGitHistorySinceDateChange,
|
||||
onGitHistoryFromTagChange,
|
||||
onGitHistoryToTagChange,
|
||||
onGitHistorySinceVersionChange,
|
||||
onIncludeMergeCommitsChange,
|
||||
baseBranch,
|
||||
compareBranch,
|
||||
onBaseBranchChange,
|
||||
onCompareBranchChange,
|
||||
onLoadCommitsPreview
|
||||
}: ChangelogFiltersProps) {
|
||||
const localBranches = branches.filter((b) => !b.isRemote);
|
||||
|
||||
return (
|
||||
<div className="w-80 flex-shrink-0 border-r border-border overflow-y-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Source Mode Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Changelog Source</Label>
|
||||
<RadioGroup
|
||||
value={sourceMode}
|
||||
onValueChange={(value) => onSourceModeChange(value as ChangelogSourceMode)}
|
||||
className="space-y-2"
|
||||
>
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all',
|
||||
sourceMode === 'tasks'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="tasks" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="font-medium text-sm">
|
||||
{CHANGELOG_SOURCE_MODE_LABELS['tasks']}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{doneTasksCount}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{CHANGELOG_SOURCE_MODE_DESCRIPTIONS['tasks']}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all',
|
||||
sourceMode === 'git-history'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="git-history" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
<span className="font-medium text-sm">
|
||||
{CHANGELOG_SOURCE_MODE_LABELS['git-history']}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{CHANGELOG_SOURCE_MODE_DESCRIPTIONS['git-history']}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all',
|
||||
sourceMode === 'branch-diff'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value="branch-diff" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span className="font-medium text-sm">
|
||||
{CHANGELOG_SOURCE_MODE_LABELS['branch-diff']}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{CHANGELOG_SOURCE_MODE_DESCRIPTIONS['branch-diff']}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Git History Options */}
|
||||
{sourceMode === 'git-history' && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Git History Options</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* History Type */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">History Type</Label>
|
||||
<Select
|
||||
value={gitHistoryType}
|
||||
onValueChange={(v) => onGitHistoryTypeChange(v as 'recent' | 'since-date' | 'tag-range' | 'since-version')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="since-version">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-3 w-3" />
|
||||
Since Version
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="recent">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-3 w-3" />
|
||||
Recent Commits
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="since-date">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Since Date
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="tag-range">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-3 w-3" />
|
||||
Tag Range
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Type-specific options */}
|
||||
{gitHistoryType === 'recent' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Number of Commits</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={gitHistoryCount}
|
||||
onChange={(e) => onGitHistoryCountChange(parseInt(e.target.value) || 25)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gitHistoryType === 'since-date' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Since Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={gitHistorySinceDate}
|
||||
onChange={(e) => onGitHistorySinceDateChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gitHistoryType === 'tag-range' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">From Tag</Label>
|
||||
<Select value={gitHistoryFromTag} onValueChange={onGitHistoryFromTagChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select tag..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tags.map((tag) => (
|
||||
<SelectItem key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">To Tag (optional)</Label>
|
||||
<Select value={gitHistoryToTag || 'HEAD'} onValueChange={(v) => onGitHistoryToTagChange(v === 'HEAD' ? '' : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="HEAD (latest)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HEAD">HEAD (latest)</SelectItem>
|
||||
{tags.map((tag) => (
|
||||
<SelectItem key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{gitHistoryType === 'since-version' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Last Version</Label>
|
||||
<Select value={gitHistorySinceVersion} onValueChange={onGitHistorySinceVersionChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select version..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tags.map((tag) => (
|
||||
<SelectItem key={tag.name} value={tag.name}>
|
||||
{tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All commits since this version will be included
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Include merge commits */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="merge-commits"
|
||||
checked={includeMergeCommits}
|
||||
onCheckedChange={(checked) => onIncludeMergeCommitsChange(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="merge-commits" className="text-xs cursor-pointer">
|
||||
Include merge commits
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Load Preview Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onLoadCommitsPreview}
|
||||
disabled={isLoadingCommits || isLoadingGitData}
|
||||
>
|
||||
{isLoadingCommits ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Load Commits
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Branch Diff Options */}
|
||||
{sourceMode === 'branch-diff' && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Branch Comparison</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Base Branch</Label>
|
||||
<Select value={baseBranch} onValueChange={onBaseBranchChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select base branch..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localBranches.map((branch) => (
|
||||
<SelectItem key={branch.name} value={branch.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
{branch.name}
|
||||
{branch.name === defaultBranch && (
|
||||
<Badge variant="outline" className="text-xs">default</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The branch you're merging into
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Compare Branch</Label>
|
||||
<Select value={compareBranch} onValueChange={onCompareBranchChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select compare branch..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localBranches.map((branch) => (
|
||||
<SelectItem key={branch.name} value={branch.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
{branch.name}
|
||||
{branch.isCurrent && (
|
||||
<Badge variant="secondary" className="text-xs">current</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The branch with your changes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{baseBranch && compareBranch && baseBranch === compareBranch && (
|
||||
<div className="flex items-center gap-2 text-destructive text-xs">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Branches must be different
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load Preview Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onLoadCommitsPreview}
|
||||
disabled={isLoadingCommits || isLoadingGitData || !baseBranch || !compareBranch || baseBranch === compareBranch}
|
||||
>
|
||||
{isLoadingCommits ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Load Commits
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { RefreshCw, Check } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { WizardStep } from './hooks/useChangelog';
|
||||
|
||||
interface ChangelogHeaderProps {
|
||||
step: WizardStep;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ChangelogHeader({ step, onRefresh }: ChangelogHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Changelog Generator</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{step === 1
|
||||
? 'Step 1: Select completed tasks to include'
|
||||
: step === 2
|
||||
? 'Step 2: Configure and generate changelog'
|
||||
: 'Step 3: Release and archive tasks'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center gap-2 mr-4">
|
||||
<StepIndicator step={1} currentStep={step} label="Select" />
|
||||
<div className="w-6 h-px bg-border" />
|
||||
<StepIndicator step={2} currentStep={step} label="Generate" />
|
||||
<div className="w-6 h-px bg-border" />
|
||||
<StepIndicator step={3} currentStep={step} label="Release" />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StepIndicatorProps {
|
||||
step: WizardStep;
|
||||
currentStep: WizardStep;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function StepIndicator({ step, currentStep, label }: StepIndicatorProps) {
|
||||
const isActive = step === currentStep;
|
||||
const isComplete = step < currentStep;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium transition-colors',
|
||||
isComplete
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isComplete ? <Check className="h-3 w-3" /> : step}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isActive ? 'text-foreground font-medium' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { FileText, GitCommit, Loader2, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { TaskCard, CommitCard } from './ChangelogEntry';
|
||||
import type { ChangelogTask, ChangelogSourceMode, GitCommit as GitCommitType } from '../../../shared/types';
|
||||
|
||||
interface ChangelogListProps {
|
||||
sourceMode: ChangelogSourceMode;
|
||||
// Task mode
|
||||
doneTasks: ChangelogTask[];
|
||||
selectedTaskIds: string[];
|
||||
onToggleTask: (taskId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
// Git mode
|
||||
previewCommits: GitCommitType[];
|
||||
isLoadingCommits: boolean;
|
||||
// Continue
|
||||
onContinue: () => void;
|
||||
canContinue: boolean;
|
||||
}
|
||||
|
||||
export function ChangelogList({
|
||||
sourceMode,
|
||||
doneTasks,
|
||||
selectedTaskIds,
|
||||
onToggleTask,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
previewCommits,
|
||||
isLoadingCommits,
|
||||
onContinue,
|
||||
canContinue
|
||||
}: ChangelogListProps) {
|
||||
// Get summary text for footer badge
|
||||
const getSummaryCount = () => {
|
||||
switch (sourceMode) {
|
||||
case 'tasks':
|
||||
return selectedTaskIds.length;
|
||||
case 'git-history':
|
||||
case 'branch-diff':
|
||||
return previewCommits.length;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const getSummaryLabel = () => {
|
||||
switch (sourceMode) {
|
||||
case 'tasks':
|
||||
return 'task';
|
||||
case 'git-history':
|
||||
case 'branch-diff':
|
||||
return 'commit';
|
||||
default:
|
||||
return 'item';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Tasks Mode - Task Selection */}
|
||||
{sourceMode === 'tasks' && (
|
||||
<>
|
||||
{/* Task selection header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3 bg-muted/30">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedTaskIds.length} of {doneTasks.length} tasks selected
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSelectAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDeselectAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task grid */}
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
{doneTasks.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center py-12">
|
||||
<FileText className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
||||
<h3 className="mt-4 text-lg font-medium">No Completed Tasks</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground max-w-md">
|
||||
Complete tasks in the Kanban board and mark them as "Done" to include them in your changelog.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{doneTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskIds.includes(task.id)}
|
||||
onToggle={() => onToggleTask(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Git History / Branch Diff Mode - Commit Preview */}
|
||||
{(sourceMode === 'git-history' || sourceMode === 'branch-diff') && (
|
||||
<>
|
||||
{/* Commit preview header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-3 bg-muted/30">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{previewCommits.length} commit{previewCommits.length !== 1 ? 's' : ''} found
|
||||
</span>
|
||||
{isLoadingCommits && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit list */}
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
{isLoadingCommits ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center py-12">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Loading commits...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : previewCommits.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center py-12">
|
||||
<GitCommit className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
||||
<h3 className="mt-4 text-lg font-medium">No Commits Found</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground max-w-md">
|
||||
{sourceMode === 'git-history'
|
||||
? 'Configure the history options and click "Load Commits" to preview.'
|
||||
: 'Select both branches and click "Load Commits" to see the changes.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{previewCommits.map((commit) => (
|
||||
<CommitCard key={commit.fullHash} commit={commit} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer with Continue button */}
|
||||
<div className="flex items-center justify-end border-t border-border px-6 py-4 bg-background">
|
||||
<Button onClick={onContinue} disabled={!canContinue} size="lg">
|
||||
Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
{canContinue && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{getSummaryCount()} {getSummaryLabel()}{getSummaryCount() !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import {
|
||||
useChangelogStore,
|
||||
loadChangelogData,
|
||||
loadGitData,
|
||||
loadCommitsPreview,
|
||||
generateChangelog,
|
||||
saveChangelog,
|
||||
copyChangelogToClipboard,
|
||||
canGenerate as canGenerateSelector
|
||||
} from '../../../stores/changelog-store';
|
||||
import { loadTasks } from '../../../stores/task-store';
|
||||
|
||||
export type WizardStep = 1 | 2 | 3;
|
||||
|
||||
export function useChangelog() {
|
||||
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
|
||||
|
||||
// Data state
|
||||
const doneTasks = useChangelogStore((state) => state.doneTasks);
|
||||
const selectedTaskIds = useChangelogStore((state) => state.selectedTaskIds);
|
||||
const existingChangelog = useChangelogStore((state) => state.existingChangelog);
|
||||
|
||||
// Source mode state
|
||||
const sourceMode = useChangelogStore((state) => state.sourceMode);
|
||||
|
||||
// Git data state
|
||||
const branches = useChangelogStore((state) => state.branches);
|
||||
const tags = useChangelogStore((state) => state.tags);
|
||||
const currentBranch = useChangelogStore((state) => state.currentBranch);
|
||||
const defaultBranch = useChangelogStore((state) => state.defaultBranch);
|
||||
const previewCommits = useChangelogStore((state) => state.previewCommits);
|
||||
const isLoadingGitData = useChangelogStore((state) => state.isLoadingGitData);
|
||||
const isLoadingCommits = useChangelogStore((state) => state.isLoadingCommits);
|
||||
|
||||
// Git history options state
|
||||
const gitHistoryType = useChangelogStore((state) => state.gitHistoryType);
|
||||
const gitHistoryCount = useChangelogStore((state) => state.gitHistoryCount);
|
||||
const gitHistorySinceDate = useChangelogStore((state) => state.gitHistorySinceDate);
|
||||
const gitHistoryFromTag = useChangelogStore((state) => state.gitHistoryFromTag);
|
||||
const gitHistoryToTag = useChangelogStore((state) => state.gitHistoryToTag);
|
||||
const gitHistorySinceVersion = useChangelogStore((state) => state.gitHistorySinceVersion);
|
||||
const includeMergeCommits = useChangelogStore((state) => state.includeMergeCommits);
|
||||
|
||||
// Branch diff options state
|
||||
const baseBranch = useChangelogStore((state) => state.baseBranch);
|
||||
const compareBranch = useChangelogStore((state) => state.compareBranch);
|
||||
|
||||
// Generation config state
|
||||
const version = useChangelogStore((state) => state.version);
|
||||
const date = useChangelogStore((state) => state.date);
|
||||
const format = useChangelogStore((state) => state.format);
|
||||
const audience = useChangelogStore((state) => state.audience);
|
||||
const customInstructions = useChangelogStore((state) => state.customInstructions);
|
||||
const generationProgress = useChangelogStore((state) => state.generationProgress);
|
||||
const generatedChangelog = useChangelogStore((state) => state.generatedChangelog);
|
||||
const isGenerating = useChangelogStore((state) => state.isGenerating);
|
||||
const error = useChangelogStore((state) => state.error);
|
||||
|
||||
// Task actions
|
||||
const toggleTaskSelection = useChangelogStore((state) => state.toggleTaskSelection);
|
||||
const selectAllTasks = useChangelogStore((state) => state.selectAllTasks);
|
||||
const deselectAllTasks = useChangelogStore((state) => state.deselectAllTasks);
|
||||
|
||||
// Source mode actions
|
||||
const setSourceMode = useChangelogStore((state) => state.setSourceMode);
|
||||
|
||||
// Git history options actions
|
||||
const setGitHistoryType = useChangelogStore((state) => state.setGitHistoryType);
|
||||
const setGitHistoryCount = useChangelogStore((state) => state.setGitHistoryCount);
|
||||
const setGitHistorySinceDate = useChangelogStore((state) => state.setGitHistorySinceDate);
|
||||
const setGitHistoryFromTag = useChangelogStore((state) => state.setGitHistoryFromTag);
|
||||
const setGitHistoryToTag = useChangelogStore((state) => state.setGitHistoryToTag);
|
||||
const setGitHistorySinceVersion = useChangelogStore((state) => state.setGitHistorySinceVersion);
|
||||
const setIncludeMergeCommits = useChangelogStore((state) => state.setIncludeMergeCommits);
|
||||
|
||||
// Branch diff options actions
|
||||
const setBaseBranch = useChangelogStore((state) => state.setBaseBranch);
|
||||
const setCompareBranch = useChangelogStore((state) => state.setCompareBranch);
|
||||
|
||||
// Generation config actions
|
||||
const setVersion = useChangelogStore((state) => state.setVersion);
|
||||
const setDate = useChangelogStore((state) => state.setDate);
|
||||
const setFormat = useChangelogStore((state) => state.setFormat);
|
||||
const setAudience = useChangelogStore((state) => state.setAudience);
|
||||
const setCustomInstructions = useChangelogStore((state) => state.setCustomInstructions);
|
||||
const updateGeneratedChangelog = useChangelogStore((state) => state.updateGeneratedChangelog);
|
||||
const setError = useChangelogStore((state) => state.setError);
|
||||
const setIsGenerating = useChangelogStore((state) => state.setIsGenerating);
|
||||
const setGenerationProgress = useChangelogStore((state) => state.setGenerationProgress);
|
||||
const reset = useChangelogStore((state) => state.reset);
|
||||
|
||||
const [step, setStep] = useState<WizardStep>(1);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [versionReason, setVersionReason] = useState<string | null>(null);
|
||||
|
||||
// Load data when project changes
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) {
|
||||
loadChangelogData(selectedProjectId);
|
||||
loadGitData(selectedProjectId);
|
||||
}
|
||||
}, [selectedProjectId]);
|
||||
|
||||
// Load commits preview when source mode or options change
|
||||
const handleLoadCommitsPreview = useCallback(() => {
|
||||
if (selectedProjectId && (sourceMode === 'git-history' || sourceMode === 'branch-diff')) {
|
||||
loadCommitsPreview(selectedProjectId);
|
||||
}
|
||||
}, [
|
||||
selectedProjectId,
|
||||
sourceMode,
|
||||
gitHistoryType,
|
||||
gitHistoryCount,
|
||||
gitHistorySinceDate,
|
||||
gitHistoryFromTag,
|
||||
gitHistoryToTag,
|
||||
gitHistorySinceVersion,
|
||||
includeMergeCommits,
|
||||
baseBranch,
|
||||
compareBranch
|
||||
]);
|
||||
|
||||
// Set up event listeners for generation
|
||||
useEffect(() => {
|
||||
const cleanupProgress = window.electronAPI.onChangelogGenerationProgress(
|
||||
(projectId, progress) => {
|
||||
if (projectId === selectedProjectId) {
|
||||
setGenerationProgress(progress);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupComplete = window.electronAPI.onChangelogGenerationComplete(
|
||||
(projectId, result) => {
|
||||
if (projectId === selectedProjectId) {
|
||||
setIsGenerating(false);
|
||||
if (result.success) {
|
||||
updateGeneratedChangelog(result.changelog);
|
||||
setGenerationProgress({
|
||||
stage: 'complete',
|
||||
progress: 100,
|
||||
message: 'Changelog generated successfully!'
|
||||
});
|
||||
} else {
|
||||
setError(result.error || 'Generation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupError = window.electronAPI.onChangelogGenerationError(
|
||||
(projectId, errorMsg) => {
|
||||
if (projectId === selectedProjectId) {
|
||||
setIsGenerating(false);
|
||||
setError(errorMsg);
|
||||
setGenerationProgress({
|
||||
stage: 'error',
|
||||
progress: 0,
|
||||
message: errorMsg,
|
||||
error: errorMsg
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
cleanupProgress();
|
||||
cleanupComplete();
|
||||
cleanupError();
|
||||
};
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (selectedProjectId) {
|
||||
generateChangelog(selectedProjectId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (selectedProjectId) {
|
||||
const success = await saveChangelog(selectedProjectId, 'prepend');
|
||||
if (success) {
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => {
|
||||
setSaveSuccess(false);
|
||||
setStep(3);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const success = copyChangelogToClipboard();
|
||||
if (success) {
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (selectedProjectId && selectedTaskIds.length > 0) {
|
||||
try {
|
||||
const result = await window.electronAPI.suggestChangelogVersion(
|
||||
selectedProjectId,
|
||||
selectedTaskIds
|
||||
);
|
||||
if (result.success && result.data) {
|
||||
setVersion(result.data.version);
|
||||
setVersionReason(result.data.reason);
|
||||
}
|
||||
} catch {
|
||||
setVersionReason(null);
|
||||
}
|
||||
}
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
const handleDone = async () => {
|
||||
reset();
|
||||
setStep(1);
|
||||
if (selectedProjectId) {
|
||||
await loadTasks(selectedProjectId);
|
||||
loadChangelogData(selectedProjectId);
|
||||
}
|
||||
};
|
||||
|
||||
const canGenerate = canGenerateSelector();
|
||||
const canSave = generatedChangelog.length > 0 && !isGenerating;
|
||||
|
||||
const canContinue = (() => {
|
||||
switch (sourceMode) {
|
||||
case 'tasks':
|
||||
return selectedTaskIds.length > 0;
|
||||
case 'git-history':
|
||||
return previewCommits.length > 0;
|
||||
case 'branch-diff':
|
||||
return baseBranch !== '' && compareBranch !== '' && baseBranch !== compareBranch && previewCommits.length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedProjectId,
|
||||
doneTasks,
|
||||
selectedTaskIds,
|
||||
existingChangelog,
|
||||
sourceMode,
|
||||
branches,
|
||||
tags,
|
||||
currentBranch,
|
||||
defaultBranch,
|
||||
previewCommits,
|
||||
isLoadingGitData,
|
||||
isLoadingCommits,
|
||||
gitHistoryType,
|
||||
gitHistoryCount,
|
||||
gitHistorySinceDate,
|
||||
gitHistoryFromTag,
|
||||
gitHistoryToTag,
|
||||
gitHistorySinceVersion,
|
||||
includeMergeCommits,
|
||||
baseBranch,
|
||||
compareBranch,
|
||||
version,
|
||||
date,
|
||||
format,
|
||||
audience,
|
||||
customInstructions,
|
||||
generationProgress,
|
||||
generatedChangelog,
|
||||
isGenerating,
|
||||
error,
|
||||
step,
|
||||
showAdvanced,
|
||||
saveSuccess,
|
||||
copySuccess,
|
||||
versionReason,
|
||||
canGenerate,
|
||||
canSave,
|
||||
canContinue,
|
||||
// Actions
|
||||
toggleTaskSelection,
|
||||
selectAllTasks,
|
||||
deselectAllTasks,
|
||||
setSourceMode,
|
||||
setGitHistoryType,
|
||||
setGitHistoryCount,
|
||||
setGitHistorySinceDate,
|
||||
setGitHistoryFromTag,
|
||||
setGitHistoryToTag,
|
||||
setGitHistorySinceVersion,
|
||||
setIncludeMergeCommits,
|
||||
setBaseBranch,
|
||||
setCompareBranch,
|
||||
setVersion,
|
||||
setDate,
|
||||
setFormat,
|
||||
setAudience,
|
||||
setCustomInstructions,
|
||||
updateGeneratedChangelog,
|
||||
setShowAdvanced,
|
||||
setStep,
|
||||
handleLoadCommitsPreview,
|
||||
handleGenerate,
|
||||
handleSave,
|
||||
handleCopy,
|
||||
handleContinue,
|
||||
handleBack,
|
||||
handleDone,
|
||||
handleRefresh: () => selectedProjectId && loadChangelogData(selectedProjectId)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Changelog } from './Changelog';
|
||||
export { ChangelogHeader } from './ChangelogHeader';
|
||||
export { ChangelogFilters } from './ChangelogFilters';
|
||||
export { ChangelogList } from './ChangelogList';
|
||||
export { TaskCard, CommitCard } from './ChangelogEntry';
|
||||
export { Step2ConfigureGenerate, Step3ReleaseArchive } from './ChangelogDetails';
|
||||
export { useChangelog } from './hooks/useChangelog';
|
||||
export type { WizardStep } from './hooks/useChangelog';
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Sparkles, FileCode, Square } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Progress } from '../ui/progress';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
IDEATION_TYPE_LABELS,
|
||||
IDEATION_TYPE_COLORS
|
||||
} from '../../../shared/constants';
|
||||
import type {
|
||||
Idea,
|
||||
IdeationType,
|
||||
IdeationGenerationStatus,
|
||||
IdeationSession
|
||||
} from '../../../shared/types';
|
||||
import type { IdeationTypeState } from '../../stores/ideation-store';
|
||||
import { TypeIcon } from './TypeIcon';
|
||||
import { TypeStateIcon } from './TypeStateIcon';
|
||||
import { IdeaSkeletonCard } from './IdeaSkeletonCard';
|
||||
import { IdeaCard } from './IdeaCard';
|
||||
import { IdeaDetailPanel } from './IdeaDetailPanel';
|
||||
|
||||
interface GenerationProgressScreenProps {
|
||||
generationStatus: IdeationGenerationStatus;
|
||||
logs: string[];
|
||||
typeStates: Record<IdeationType, IdeationTypeState>;
|
||||
enabledTypes: IdeationType[];
|
||||
session: IdeationSession | null;
|
||||
onSelectIdea: (idea: Idea | null) => void;
|
||||
selectedIdea: Idea | null;
|
||||
onConvert: (idea: Idea) => void;
|
||||
onDismiss: (idea: Idea) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function GenerationProgressScreen({
|
||||
generationStatus,
|
||||
logs,
|
||||
typeStates,
|
||||
enabledTypes,
|
||||
session,
|
||||
onSelectIdea,
|
||||
selectedIdea,
|
||||
onConvert,
|
||||
onDismiss,
|
||||
onStop
|
||||
}: GenerationProgressScreenProps) {
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
|
||||
// Auto-scroll to bottom when logs update
|
||||
useEffect(() => {
|
||||
if (logsEndRef.current && showLogs) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs, showLogs]);
|
||||
|
||||
// Get ideas for a specific type from the current session
|
||||
const getStreamingIdeasByType = (type: IdeationType): Idea[] => {
|
||||
if (!session) return [];
|
||||
return session.ideas.filter((idea) => idea.type === type);
|
||||
};
|
||||
|
||||
// Count how many types are still generating
|
||||
const generatingCount = enabledTypes.filter((t) => typeStates[t] === 'generating').length;
|
||||
const completedCount = enabledTypes.filter((t) => typeStates[t] === 'completed').length;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 border-b border-border p-4 bg-card/50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Sparkles className="h-5 w-5 text-primary animate-pulse" />
|
||||
<h2 className="text-lg font-semibold">Generating Ideas</h2>
|
||||
<Badge variant="outline">
|
||||
{completedCount}/{enabledTypes.length} complete
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{generationStatus.message}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
<FileCode className="h-4 w-4 mr-1" />
|
||||
{showLogs ? 'Hide' : 'Show'} Logs
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onStop}
|
||||
>
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stop generation</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={generationStatus.progress} className="mt-3" />
|
||||
|
||||
{/* Type Status Indicators */}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{enabledTypes.map((type) => (
|
||||
<div
|
||||
key={type}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs ${
|
||||
typeStates[type] === 'completed'
|
||||
? 'bg-success/10 text-success'
|
||||
: typeStates[type] === 'failed'
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: typeStates[type] === 'generating'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<TypeStateIcon state={typeStates[type]} />
|
||||
<TypeIcon type={type} />
|
||||
<span>{IDEATION_TYPE_LABELS[type]}</span>
|
||||
{typeStates[type] === 'completed' && session && (
|
||||
<span className="ml-1 font-medium">
|
||||
({getStreamingIdeasByType(type).length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Panel (collapsible) */}
|
||||
{showLogs && logs.length > 0 && (
|
||||
<div className="flex-shrink-0 border-b border-border p-4 bg-muted/20">
|
||||
<ScrollArea className="h-32 rounded-md border border-border bg-muted/30">
|
||||
<div className="p-3 space-y-1 font-mono text-xs">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="text-muted-foreground leading-relaxed">
|
||||
<span className="text-muted-foreground/50 mr-2 select-none">
|
||||
{String(index + 1).padStart(3, '0')}
|
||||
</span>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming Ideas View */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{generationStatus.error && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 rounded-md text-destructive text-sm">
|
||||
{generationStatus.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{enabledTypes.map((type) => {
|
||||
const ideas = getStreamingIdeasByType(type);
|
||||
const state = typeStates[type];
|
||||
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className={`p-1.5 rounded-md ${IDEATION_TYPE_COLORS[type]}`}>
|
||||
<TypeIcon type={type} />
|
||||
</div>
|
||||
<h3 className="font-medium">{IDEATION_TYPE_LABELS[type]}</h3>
|
||||
<TypeStateIcon state={state} />
|
||||
{ideas.length > 0 && (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{ideas.length} ideas
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{/* Show actual ideas if available */}
|
||||
{ideas.map((idea) => (
|
||||
<IdeaCard
|
||||
key={idea.id}
|
||||
idea={idea}
|
||||
onClick={() => onSelectIdea(selectedIdea?.id === idea.id ? null : idea)}
|
||||
onConvert={onConvert}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show skeleton placeholders while generating */}
|
||||
{state === 'generating' && (
|
||||
<>
|
||||
<IdeaSkeletonCard />
|
||||
<IdeaSkeletonCard />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show pending message */}
|
||||
{state === 'pending' && (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
Waiting to start...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show failed message */}
|
||||
{state === 'failed' && ideas.length === 0 && (
|
||||
<div className="text-sm text-destructive py-2">
|
||||
Failed to generate ideas for this category
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show empty message if completed with no ideas */}
|
||||
{state === 'completed' && ideas.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
No ideas generated for this category
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Idea Detail Panel */}
|
||||
{selectedIdea && (
|
||||
<IdeaDetailPanel
|
||||
idea={selectedIdea}
|
||||
onClose={() => onSelectIdea(null)}
|
||||
onConvert={onConvert}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user