diff --git a/.gitignore b/.gitignore index cbfee1f1..27835c3e 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,5 @@ OPUS_ANALYSIS_AND_IDEAS.md # Auto Claude generated files .security-key /shared_docs -Agents.md \ No newline at end of file +logs/security/ +Agents.md diff --git a/apps/frontend/node_modules b/apps/frontend/node_modules new file mode 120000 index 00000000..b9168ccd --- /dev/null +++ b/apps/frontend/node_modules @@ -0,0 +1 @@ +../../../../../../apps/frontend/node_modules \ No newline at end of file diff --git a/apps/frontend/src/__tests__/integration/ipc-bridge.test.ts b/apps/frontend/src/__tests__/integration/ipc-bridge.test.ts index 1bfd9228..123b76fa 100644 --- a/apps/frontend/src/__tests__/integration/ipc-bridge.test.ts +++ b/apps/frontend/src/__tests__/integration/ipc-bridge.test.ts @@ -108,7 +108,8 @@ describe('IPC Bridge Integration', () => { const getTasks = electronAPI['getTasks'] as (projectId: string) => Promise; await getTasks('project-id'); - expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('task:list', 'project-id'); + // Second argument is optional options (undefined when not provided) + expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('task:list', 'project-id', undefined); }); it('should have createTask method', async () => { diff --git a/apps/frontend/src/__tests__/integration/task-lifecycle.test.ts b/apps/frontend/src/__tests__/integration/task-lifecycle.test.ts index cf6641d0..fffbed82 100644 --- a/apps/frontend/src/__tests__/integration/task-lifecycle.test.ts +++ b/apps/frontend/src/__tests__/integration/task-lifecycle.test.ts @@ -148,8 +148,8 @@ describe('Task Lifecycle Integration', () => { const getTasks = electronAPI['getTasks'] as (projectId: string) => Promise; const result = await getTasks('project-id'); - // Verify IPC invocation - expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('task:list', 'project-id'); + // Verify IPC invocation - second argument is optional options (undefined when not provided) + expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('task:list', 'project-id', undefined); // Verify task data includes plan with subtasks expect(result).toMatchObject({ diff --git a/apps/frontend/src/main/__tests__/project-store.test.ts b/apps/frontend/src/main/__tests__/project-store.test.ts index 585c6ebc..d39f79d9 100644 --- a/apps/frontend/src/main/__tests__/project-store.test.ts +++ b/apps/frontend/src/main/__tests__/project-store.test.ts @@ -3,13 +3,14 @@ * Tests project CRUD operations and task reading from filesystem */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs'; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; import path from 'path'; -// Test directories -const TEST_DIR = '/tmp/project-store-test'; -const USER_DATA_PATH = path.join(TEST_DIR, 'userData'); -const TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project'); +// Test directories - will be set in beforeEach with unique temp dir +let TEST_DIR: string; +let USER_DATA_PATH: string; +let TEST_PROJECT_PATH: string; // Mock Electron before importing the store vi.mock('electron', () => ({ @@ -21,8 +22,13 @@ vi.mock('electron', () => ({ } })); -// Setup test directories +// Setup test directories with unique secure temp dir function setupTestDirs(): void { + // Create a unique, secure temporary directory + TEST_DIR = mkdtempSync(path.join(tmpdir(), 'project-store-test-')); + USER_DATA_PATH = path.join(TEST_DIR, 'userData'); + TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project'); + mkdirSync(USER_DATA_PATH, { recursive: true }); mkdirSync(path.join(USER_DATA_PATH, 'store'), { recursive: true }); mkdirSync(TEST_PROJECT_PATH, { recursive: true }); @@ -585,4 +591,381 @@ describe('ProjectStore', () => { expect(projects).toEqual([]); }); }); + + describe('archiveTasks - multi-location handling', () => { + it('should archive task from main specs directory only', async () => { + // Create spec directory in main location only + const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '001-test-task'); + mkdirSync(specsDir, { recursive: true }); + + const plan = { + feature: 'Test Feature', + workflow_type: 'feature', + services_involved: [], + phases: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + spec_file: 'spec.md' + }; + writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(plan)); + + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + const project = store.addProject(TEST_PROJECT_PATH); + const result = store.archiveTasks(project.id, ['001-test-task'], '1.0.0'); + + expect(result).toBe(true); + + // Verify metadata was created with archive info + const metadataPath = path.join(specsDir, 'task_metadata.json'); + expect(existsSync(metadataPath)).toBe(true); + + const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')); + expect(metadata.archivedAt).toBeDefined(); + expect(metadata.archivedInVersion).toBe('1.0.0'); + }); + + it('should archive task from BOTH main and worktree locations', async () => { + // Create spec directory in main location + const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '002-multi-location'); + mkdirSync(mainSpecsDir, { recursive: true }); + + // Create spec directory in worktree location + // Worktree path: .auto-claude/worktrees/tasks//.auto-claude/specs/ + const worktreeDir = path.join( + TEST_PROJECT_PATH, + '.auto-claude', + 'worktrees', + 'tasks', + 'my-worktree', + '.auto-claude', + 'specs', + '002-multi-location' + ); + mkdirSync(worktreeDir, { recursive: true }); + + const plan = { + feature: 'Multi-Location Feature', + workflow_type: 'feature', + services_involved: [], + phases: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + spec_file: 'spec.md' + }; + + writeFileSync(path.join(mainSpecsDir, 'implementation_plan.json'), JSON.stringify(plan)); + writeFileSync(path.join(worktreeDir, 'implementation_plan.json'), JSON.stringify(plan)); + + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + const project = store.addProject(TEST_PROJECT_PATH); + const result = store.archiveTasks(project.id, ['002-multi-location'], '2.0.0'); + + expect(result).toBe(true); + + // Verify metadata was created in BOTH locations + const mainMetadataPath = path.join(mainSpecsDir, 'task_metadata.json'); + const worktreeMetadataPath = path.join(worktreeDir, 'task_metadata.json'); + + expect(existsSync(mainMetadataPath)).toBe(true); + expect(existsSync(worktreeMetadataPath)).toBe(true); + + const mainMetadata = JSON.parse(readFileSync(mainMetadataPath, 'utf-8')); + const worktreeMetadata = JSON.parse(readFileSync(worktreeMetadataPath, 'utf-8')); + + expect(mainMetadata.archivedAt).toBeDefined(); + expect(mainMetadata.archivedInVersion).toBe('2.0.0'); + expect(worktreeMetadata.archivedAt).toBeDefined(); + expect(worktreeMetadata.archivedInVersion).toBe('2.0.0'); + }); + + it('should handle task that exists only in worktree', async () => { + // Create spec directory ONLY in worktree location (not in main) + const worktreeDir = path.join( + TEST_PROJECT_PATH, + '.auto-claude', + 'worktrees', + 'tasks', + 'only-worktree', + '.auto-claude', + 'specs', + '003-worktree-only' + ); + mkdirSync(worktreeDir, { recursive: true }); + + const plan = { + feature: 'Worktree Only Feature', + workflow_type: 'feature', + services_involved: [], + phases: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + spec_file: 'spec.md' + }; + writeFileSync(path.join(worktreeDir, 'implementation_plan.json'), JSON.stringify(plan)); + + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + const project = store.addProject(TEST_PROJECT_PATH); + const result = store.archiveTasks(project.id, ['003-worktree-only'], '1.0.0'); + + expect(result).toBe(true); + + // Verify metadata was created in worktree + const metadataPath = path.join(worktreeDir, 'task_metadata.json'); + expect(existsSync(metadataPath)).toBe(true); + + const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')); + expect(metadata.archivedAt).toBeDefined(); + }); + + it('should skip non-existent task gracefully', async () => { + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + // Create .auto-claude directory so project is recognized + mkdirSync(path.join(TEST_PROJECT_PATH, '.auto-claude'), { recursive: true }); + + const project = store.addProject(TEST_PROJECT_PATH); + // Task doesn't exist anywhere + const result = store.archiveTasks(project.id, ['nonexistent-task']); + + // Should return true (no errors) since missing tasks are skipped + expect(result).toBe(true); + }); + + it('should reject path traversal attempts in taskId', async () => { + // Create a valid spec dir + const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', 'valid-task'); + mkdirSync(specsDir, { recursive: true }); + + const plan = { feature: 'Test', phases: [] }; + writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(plan)); + + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + const project = store.addProject(TEST_PROJECT_PATH); + + // Try various path traversal attacks + const maliciousIds = [ + '../../../etc/passwd', + '..\\..\\windows\\system32', + 'task/../../../secret', + '.', + '..', + 'task\0.json' + ]; + + for (const maliciousId of maliciousIds) { + // These should be rejected and not cause any file operations + const result = store.archiveTasks(project.id, [maliciousId]); + // Should return true since invalid IDs are skipped, not treated as errors + expect(result).toBe(true); + } + }); + + it('should return false for non-existent project', async () => { + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + const result = store.archiveTasks('nonexistent-project-id', ['some-task']); + + expect(result).toBe(false); + }); + }); + + describe('unarchiveTasks - multi-location handling', () => { + it('should unarchive task from BOTH main and worktree locations', async () => { + // Create archived task in both locations + const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '004-unarchive-test'); + mkdirSync(mainSpecsDir, { recursive: true }); + + const worktreeDir = path.join( + TEST_PROJECT_PATH, + '.auto-claude', + 'worktrees', + 'tasks', + 'unarchive-worktree', + '.auto-claude', + 'specs', + '004-unarchive-test' + ); + mkdirSync(worktreeDir, { recursive: true }); + + const plan = { + feature: 'Unarchive Test', + phases: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z' + }; + + const archivedMetadata = { + archivedAt: '2024-06-01T00:00:00Z', + archivedInVersion: '1.0.0' + }; + + // Create plan and archived metadata in both locations + writeFileSync(path.join(mainSpecsDir, 'implementation_plan.json'), JSON.stringify(plan)); + writeFileSync(path.join(mainSpecsDir, 'task_metadata.json'), JSON.stringify(archivedMetadata)); + writeFileSync(path.join(worktreeDir, 'implementation_plan.json'), JSON.stringify(plan)); + writeFileSync(path.join(worktreeDir, 'task_metadata.json'), JSON.stringify(archivedMetadata)); + + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + const project = store.addProject(TEST_PROJECT_PATH); + const result = store.unarchiveTasks(project.id, ['004-unarchive-test']); + + expect(result).toBe(true); + + // Verify archivedAt was removed from BOTH locations + const mainMetadata = JSON.parse(readFileSync(path.join(mainSpecsDir, 'task_metadata.json'), 'utf-8')); + const worktreeMetadata = JSON.parse(readFileSync(path.join(worktreeDir, 'task_metadata.json'), 'utf-8')); + + expect(mainMetadata.archivedAt).toBeUndefined(); + expect(mainMetadata.archivedInVersion).toBeUndefined(); + expect(worktreeMetadata.archivedAt).toBeUndefined(); + expect(worktreeMetadata.archivedInVersion).toBeUndefined(); + }); + }); + + describe('cache invalidation', () => { + it('should invalidate cache after archiveTasks', async () => { + const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '005-cache-test'); + mkdirSync(specsDir, { recursive: true }); + + const plan = { + feature: 'Cache Test Feature', + workflow_type: 'feature', + services_involved: [], + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [{ id: 'subtask-1', description: 'Test', status: 'pending' }] + } + ], + final_acceptance: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + spec_file: 'spec.md' + }; + writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(plan)); + + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + const project = store.addProject(TEST_PROJECT_PATH); + + // First call should populate cache + const tasksBefore = store.getTasks(project.id); + expect(tasksBefore).toHaveLength(1); + expect(tasksBefore[0].metadata?.archivedAt).toBeUndefined(); + + // Archive the task + store.archiveTasks(project.id, ['005-cache-test']); + + // After archiving, cache should be invalidated and getTasks should return updated data + const tasksAfter = store.getTasks(project.id); + expect(tasksAfter[0].metadata?.archivedAt).toBeDefined(); + }); + + it('should return fresh data after invalidateTasksCache is called', async () => { + const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '006-invalidate-test'); + mkdirSync(specsDir, { recursive: true }); + + const plan = { + feature: 'Initial Feature', + workflow_type: 'feature', + services_involved: [], + phases: [], + final_acceptance: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + spec_file: 'spec.md' + }; + writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(plan)); + + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + const project = store.addProject(TEST_PROJECT_PATH); + + // First call should populate cache + const tasksBefore = store.getTasks(project.id); + expect(tasksBefore[0].title).toBe('Initial Feature'); + + // Modify the file directly (simulating external change) + const updatedPlan = { ...plan, feature: 'Updated Feature' }; + writeFileSync(path.join(specsDir, 'implementation_plan.json'), JSON.stringify(updatedPlan)); + + // Without invalidation, should still return cached data + const tasksCached = store.getTasks(project.id); + expect(tasksCached[0].title).toBe('Initial Feature'); + + // Invalidate cache + store.invalidateTasksCache(project.id); + + // Now should return fresh data + const tasksAfterInvalidation = store.getTasks(project.id); + expect(tasksAfterInvalidation[0].title).toBe('Updated Feature'); + }); + }); + + describe('getTasks - worktree deduplication', () => { + it('should not duplicate tasks that exist in both main and worktree', async () => { + // Create same task in both main and worktree + const mainSpecsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '007-dedupe-test'); + mkdirSync(mainSpecsDir, { recursive: true }); + + const worktreeDir = path.join( + TEST_PROJECT_PATH, + '.auto-claude', + 'worktrees', + 'tasks', + 'dedupe-worktree', + '.auto-claude', + 'specs', + '007-dedupe-test' + ); + mkdirSync(worktreeDir, { recursive: true }); + + const plan = { + feature: 'Dedupe Test Feature', + workflow_type: 'feature', + services_involved: [], + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [{ id: 'subtask-1', description: 'Test', status: 'pending' }] + } + ], + final_acceptance: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + spec_file: 'spec.md' + }; + + writeFileSync(path.join(mainSpecsDir, 'implementation_plan.json'), JSON.stringify(plan)); + writeFileSync(path.join(worktreeDir, 'implementation_plan.json'), JSON.stringify(plan)); + + const { ProjectStore } = await import('../project-store'); + const store = new ProjectStore(); + + const project = store.addProject(TEST_PROJECT_PATH); + const tasks = store.getTasks(project.id); + + // Should only return ONE task, not two + const matchingTasks = tasks.filter(t => t.specId === '007-dedupe-test'); + expect(matchingTasks).toHaveLength(1); + }); + }); }); diff --git a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts index 50049f06..98d2bda3 100644 --- a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts @@ -2,11 +2,12 @@ import { ipcMain } from 'electron'; import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants'; import type { IPCResult, Task, TaskMetadata } from '../../../shared/types'; import path from 'path'; -import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, Dirent } from 'fs'; import { projectStore } from '../../project-store'; import { titleGenerator } from '../../title-generator'; import { AgentManager } from '../../agent'; import { findTaskAndProject } from './shared'; +import { findAllSpecPaths } from '../../utils/spec-path-helpers'; /** * Register task CRUD (Create, Read, Update, Delete) handlers @@ -14,11 +15,22 @@ import { findTaskAndProject } from './shared'; export function registerTaskCRUDHandlers(agentManager: AgentManager): void { /** * List all tasks for a project + * @param projectId - The project ID to fetch tasks for + * @param options - Optional parameters + * @param options.forceRefresh - If true, invalidates cache before fetching (for refresh button) */ ipcMain.handle( IPC_CHANNELS.TASK_LIST, - async (_, projectId: string): Promise> => { - console.warn('[IPC] TASK_LIST called with projectId:', projectId); + async (_, projectId: string, options?: { forceRefresh?: boolean }): Promise> => { + console.warn('[IPC] TASK_LIST called with projectId:', projectId, 'options:', options); + + // If forceRefresh is requested, invalidate cache first + // This ensures the refresh button always returns fresh data from disk + if (options?.forceRefresh) { + projectStore.invalidateTasksCache(projectId); + console.warn('[IPC] TASK_LIST cache invalidated for forceRefresh'); + } + const tasks = projectStore.getTasks(projectId); console.warn('[IPC] TASK_LIST returning', tasks.length, 'tasks'); return { success: true, data: tasks }; @@ -73,16 +85,16 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { let specNumber = 1; if (existsSync(specsDir)) { const existingDirs = readdirSync(specsDir, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name); + .filter((d: Dirent) => d.isDirectory()) + .map((d: Dirent) => d.name); // Extract numbers from spec directory names (e.g., "001-feature" -> 1) const existingNumbers = existingDirs - .map(name => { + .map((name: string) => { const match = name.match(/^(\d+)/); return match ? parseInt(match[1], 10) : 0; }) - .filter(n => n > 0); + .filter((n: number) => n > 0); if (existingNumbers.length > 0) { specNumber = Math.max(...existingNumbers) + 1; @@ -222,29 +234,47 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { return { success: false, error: 'Cannot delete a running task. Stop the task first.' }; } - // Delete the spec directory - use task.specsPath if available (handles worktree tasks) - const specDir = task.specsPath || path.join(project.path, getSpecsDir(project.autoBuildPath), task.specId); + // Find ALL locations where this task exists (main + worktrees) + // Following the archiveTasks() pattern from project-store.ts + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specPaths = findAllSpecPaths(project.path, specsBaseDir, task.specId); - try { - console.warn(`[TASK_DELETE] Attempting to delete: ${specDir} (location: ${task.location || 'unknown'})`); - if (existsSync(specDir)) { + // If spec directory doesn't exist anywhere, return success (already removed) + if (specPaths.length === 0) { + console.warn(`[TASK_DELETE] No spec directories found for task ${taskId} - already removed`); + projectStore.invalidateTasksCache(project.id); + return { success: true }; + } + + let hasErrors = false; + const errors: string[] = []; + + // Delete from ALL locations + for (const specDir of specPaths) { + try { + console.warn(`[TASK_DELETE] Attempting to delete: ${specDir}`); await rm(specDir, { recursive: true, force: true }); console.warn(`[TASK_DELETE] Deleted spec directory: ${specDir}`); - } else { - console.warn(`[TASK_DELETE] Spec directory not found: ${specDir}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[TASK_DELETE] Error deleting spec directory ${specDir}:`, error); + hasErrors = true; + errors.push(`${specDir}: ${errorMsg}`); + // Continue with other locations even if one fails } + } - // Invalidate cache since a task was deleted - projectStore.invalidateTasksCache(project.id); + // Invalidate cache since a task was deleted + projectStore.invalidateTasksCache(project.id); - return { success: true }; - } catch (error) { - console.error('[TASK_DELETE] Error deleting spec directory:', error); + if (hasErrors) { return { success: false, - error: error instanceof Error ? error.message : 'Failed to delete task files' + error: `Failed to delete some task files: ${errors.join('; ')}` }; } + + return { success: true }; } ); diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index 6c864e78..47eb2319 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -391,6 +391,7 @@ export function registerTaskExecutionHandlers( return { success: false, error: 'Failed to write QA report file' }; } + // Notify UI immediately for instant feedback const mainWindow = getMainWindow(); if (mainWindow) { mainWindow.webContents.send( @@ -399,6 +400,20 @@ export function registerTaskExecutionHandlers( 'done' ); } + + // CRITICAL: Persist 'done' status to implementation_plan.json + // Without this, the old status would be shown after page refresh since + // getTasks() reads status from the plan file, not from the Zustand store. + const planPath = getPlanPath(project, task); + try { + const persisted = await persistPlanStatus(planPath, 'done', project.id); + if (persisted) { + console.warn('[TASK_REVIEW] Persisted approved status (done) to implementation_plan.json'); + } + } catch (err) { + console.error('[TASK_REVIEW] Failed to persist approved status:', err); + // Non-fatal: UI already updated, file persistence is best-effort + } } else { // Reset and discard all changes from worktree merge in main // The worktree still has all changes, so nothing is lost @@ -519,6 +534,7 @@ export function registerTaskExecutionHandlers( console.warn('[TASK_REVIEW] Starting QA process with projectPath:', qaProjectPath); agentManager.startQAProcess(taskId, qaProjectPath, task.specId); + // Notify UI immediately for instant feedback const mainWindow = getMainWindow(); if (mainWindow) { mainWindow.webContents.send( @@ -527,6 +543,20 @@ export function registerTaskExecutionHandlers( 'in_progress' ); } + + // CRITICAL: Persist 'in_progress' status to implementation_plan.json + // Without this, the old status (e.g., 'human_review') would be shown after page refresh + // since getTasks() reads status from the plan file, not from the Zustand store. + const planPath = getPlanPath(project, task); + try { + const persisted = await persistPlanStatus(planPath, 'in_progress', project.id); + if (persisted) { + console.warn('[TASK_REVIEW] Persisted rejected status (in_progress) to implementation_plan.json'); + } + } catch (err) { + console.error('[TASK_REVIEW] Failed to persist rejected status:', err); + // Non-fatal: UI already updated, file persistence is best-effort + } } return { success: true }; @@ -964,6 +994,10 @@ export function registerTaskExecutionHandlers( }; } + // CRITICAL: Invalidate cache AFTER file writes complete + // This ensures getTasks() returns fresh data reflecting the recovery + projectStore.invalidateTasksCache(project.id); + return { success: true, data: { @@ -1025,6 +1059,10 @@ export function registerTaskExecutionHandlers( error: 'Failed to write plan file during recovery' }; } + + // CRITICAL: Invalidate cache AFTER file writes complete + // This ensures getTasks() returns fresh data reflecting the recovery + projectStore.invalidateTasksCache(project.id); } // Stop file watcher if it was watching this task @@ -1101,6 +1139,10 @@ export function registerTaskExecutionHandlers( // The plan status will be updated by the agent when it starts } } + + // CRITICAL: Invalidate cache AFTER file writes complete + // This ensures getTasks() returns fresh data reflecting the restart status + projectStore.invalidateTasksCache(project.id); } // Start the task execution diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index 5f1d8e72..cdb6bfcf 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -6,6 +6,8 @@ import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, Implemen import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants'; import { getAutoBuildPath, isInitialized } from './project-initializer'; import { getTaskWorktreeDir } from './worktree-paths'; +import { debugLog } from '../shared/utils/debug-logger'; +import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers'; interface TabState { openProjectIds: string[]; @@ -255,17 +257,17 @@ export class ProjectStore { return cached.tasks; } - console.warn('[ProjectStore] getTasks called - will load from disk', { + debugLog('[ProjectStore] getTasks called - will load from disk', { projectId, reason: cached ? 'cache expired' : 'cache miss', cacheAge: cached ? now - cached.timestamp : 'N/A' }); const project = this.getProject(projectId); if (!project) { - console.warn('[ProjectStore] Project not found for id:', projectId); + debugLog('[ProjectStore] Project not found for id:', projectId); return []; } - console.warn('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath, 'path:', project.path); + debugLog('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath, 'path:', project.path); const allTasks: Task[] = []; const specsBaseDir = getSpecsDir(project.autoBuildPath); @@ -536,13 +538,18 @@ export class ProjectStore { /** * Determine task status and review reason based on plan and files. * - * This method calculates the correct status from subtask progress and QA state, - * providing backwards compatibility for existing tasks with incorrect status. + * PRIORITY ORDER (to prevent status flip-flop during execution): + * 1. Terminal statuses (done, pr_created, error) - ALWAYS respected + * 2. Active process statuses (planning, coding, in_progress) - respected during execution + * 3. Explicit human_review with reviewReason - respected to prevent recalculation + * 4. QA report file status + * 5. Calculated status from subtask analysis (fallback only) * * Review reasons: * - 'completed': All subtasks done, QA passed - ready for merge * - 'errors': Subtasks failed during execution - needs attention * - 'qa_rejected': QA found issues that need fixing + * - 'plan_review': Spec creation complete, awaiting user approval */ private determineTaskStatusAndReason( plan: ImplementationPlan | null, @@ -552,6 +559,135 @@ export class ProjectStore { // 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) || []; + // Status mapping from plan.status values to TaskStatus + const statusMap: Record = { + 'pending': 'backlog', + 'planning': 'in_progress', + 'in_progress': 'in_progress', + 'coding': 'in_progress', + 'review': 'ai_review', + 'completed': 'done', + 'done': 'done', + 'human_review': 'human_review', + 'ai_review': 'ai_review', + 'pr_created': 'pr_created', + 'backlog': 'backlog', + 'error': 'error' + }; + + // Terminal statuses that should NEVER be overridden by calculation + const TERMINAL_STATUSES = new Set(['done', 'pr_created', 'error']); + + // ======================================================================== + // STEP 1: Check for terminal statuses (highest priority - always respected) + // ======================================================================== + if (plan?.status) { + const storedStatus = statusMap[plan.status]; + if (storedStatus && TERMINAL_STATUSES.has(storedStatus)) { + debugLog('[determineTaskStatusAndReason] Terminal status respected:', { + planStatus: plan.status, + mappedStatus: storedStatus, + reason: 'Terminal statuses (done, pr_created, error) are never overridden' + }); + return { status: storedStatus }; + } + } + + // ======================================================================== + // STEP 2: Check for active process statuses during execution + // These prevent status flip-flop while backend is actively running + // ======================================================================== + if (plan?.status) { + const storedStatus = statusMap[plan.status]; + const rawStatus = plan.status as string; + const isActiveProcessStatus = rawStatus === 'planning' || rawStatus === 'coding' || rawStatus === 'in_progress'; + + // Check if this is a plan review stage (spec creation complete, awaiting approval) + const isPlanReviewStage = (plan as unknown as { planStatus?: string })?.planStatus === 'review'; + + // During active execution, respect the stored status to prevent jumping + if (isActiveProcessStatus && storedStatus === 'in_progress') { + debugLog('[determineTaskStatusAndReason] Active process status preserved:', { + planStatus: plan.status, + mappedStatus: storedStatus, + reason: 'Execution in progress - status recalculation blocked' + }); + return { status: 'in_progress' }; + } + + // Plan review stage (human approval of spec before coding starts) + if (isPlanReviewStage && storedStatus === 'human_review') { + debugLog('[determineTaskStatusAndReason] Plan review stage detected:', { + planStatus: plan.status, + reason: 'Spec creation complete, awaiting user approval' + }); + return { status: 'human_review', reviewReason: 'plan_review' }; + } + + // Explicit human_review status should be preserved unless we have evidence to change it + if (storedStatus === 'human_review') { + // Infer review reason from subtask/QA state + const hasFailedSubtasks = allSubtasks.some((s) => s.status === 'failed'); + const allCompleted = allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed'); + let reviewReason: ReviewReason | undefined; + if (hasFailedSubtasks) { + reviewReason = 'errors'; + } else if (allCompleted) { + reviewReason = 'completed'; + } + debugLog('[determineTaskStatusAndReason] Explicit human_review preserved:', { + planStatus: plan.status, + reviewReason, + hasFailedSubtasks, + allCompleted, + subtaskCount: allSubtasks.length + }); + return { status: 'human_review', reviewReason }; + } + + // Explicit ai_review status should be preserved + if (storedStatus === 'ai_review') { + debugLog('[determineTaskStatusAndReason] Explicit ai_review preserved:', { + planStatus: plan.status, + subtaskCount: allSubtasks.length + }); + return { status: 'ai_review' }; + } + } + + // ======================================================================== + // STEP 3: Check QA report file for status info + // ======================================================================== + const qaReportPath = path.join(specPath, AUTO_BUILD_PATHS.QA_REPORT); + if (existsSync(qaReportPath)) { + try { + const content = readFileSync(qaReportPath, 'utf-8'); + if (content.includes('REJECTED') || content.includes('FAILED')) { + debugLog('[determineTaskStatusAndReason] QA report indicates rejection:', { + qaReportPath, + reason: 'QA rejected - needs human attention' + }); + return { status: 'human_review', reviewReason: 'qa_rejected' }; + } + if (content.includes('PASSED') || content.includes('APPROVED')) { + // QA passed - if all subtasks done, move to human_review + if (allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed')) { + debugLog('[determineTaskStatusAndReason] QA passed with all subtasks complete:', { + qaReportPath, + subtaskCount: allSubtasks.length + }); + return { status: 'human_review', reviewReason: 'completed' }; + } + } + } catch { + // Ignore read errors + } + } + + // ======================================================================== + // STEP 4: Calculate status from subtask analysis (fallback only) + // This is the lowest priority - only used when no explicit status is set + // ======================================================================== let calculatedStatus: TaskStatus = 'backlog'; let reviewReason: ReviewReason | undefined; @@ -582,148 +718,24 @@ export class ProjectStore { } } - // FIRST: Check for explicit user-set status from plan (takes highest priority) - // This allows users to manually mark tasks as 'done' via drag-and-drop - if (plan?.status) { - const statusMap: Record = { - 'pending': 'backlog', - 'planning': 'in_progress', // Task is in planning phase (spec creation running) - 'in_progress': 'in_progress', - 'coding': 'in_progress', // Task is in coding phase - 'review': 'ai_review', - 'completed': 'done', - 'done': 'done', - 'human_review': 'human_review', - 'ai_review': 'ai_review', - 'pr_created': 'pr_created', // PR has been created for this task - 'backlog': 'backlog' - }; - const storedStatus = statusMap[plan.status]; - - // If user explicitly marked as 'done', always respect that - if (storedStatus === 'done') { - return { status: 'done' }; - } - - // If task has a PR created, always respect that status - if (storedStatus === 'pr_created') { - return { status: 'pr_created' }; - } - - // For other stored statuses, validate against calculated status - 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 as string) === 'planning' || (plan.status as string) === 'coding' || (plan.status as string) === 'in_progress'; - - // 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'; - - // Determine if there is remaining work to do - // True if: no subtasks exist yet (planning in progress) OR some subtasks are incomplete - // This prevents 'in_progress' from overriding 'human_review' when all work is done - const hasRemainingWork = allSubtasks.length === 0 || allSubtasks.some((s) => s.status !== 'completed'); - - const isStoredStatusValid = - (storedStatus === calculatedStatus) || // Matches calculated - (storedStatus === 'human_review' && (calculatedStatus === 'ai_review' || calculatedStatus === 'in_progress')) || // Human review is more advanced than ai_review or in_progress (fixes status loop bug) - (storedStatus === 'human_review' && isPlanReviewStage) || // Plan review stage (awaiting spec approval) - (isActiveProcessStatus && storedStatus === 'in_progress' && hasRemainingWork); // Planning/coding phases should show as in_progress ONLY when there's remaining work - - if (isStoredStatusValid) { - // Preserve reviewReason for human_review status - if (storedStatus === 'human_review' && !reviewReason) { - // 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 }; - } - } - } - - // SECOND: Check QA report file for additional status info - const qaReportPath = path.join(specPath, AUTO_BUILD_PATHS.QA_REPORT); - if (existsSync(qaReportPath)) { - try { - const content = readFileSync(qaReportPath, 'utf-8'); - if (content.includes('REJECTED') || content.includes('FAILED')) { - return { status: 'human_review', reviewReason: 'qa_rejected' }; - } - if (content.includes('PASSED') || content.includes('APPROVED')) { - // QA passed - if all subtasks done, move to human_review - if (allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed')) { - return { status: 'human_review', reviewReason: 'completed' }; - } - } - } catch { - // Ignore read errors - } - } + // Log calculated status (fallback path - no explicit status was set) + debugLog('[determineTaskStatusAndReason] Status calculated from subtasks (fallback):', { + planStatus: plan?.status || 'none', + calculatedStatus, + reviewReason, + subtaskStats: { + total: allSubtasks.length, + completed: allSubtasks.filter((s) => s.status === 'completed').length, + inProgress: allSubtasks.filter((s) => s.status === 'in_progress').length, + failed: allSubtasks.filter((s) => s.status === 'failed').length, + pending: allSubtasks.filter((s) => s.status === 'pending').length + }, + isManual: metadata?.sourceType === 'manual' + }); return { status: calculatedStatus, reviewReason: calculatedStatus === 'human_review' ? reviewReason : undefined }; } - /** - * Validate taskId to prevent path traversal attacks - * Returns true if taskId is safe to use in path operations - */ - private isValidTaskId(taskId: string): boolean { - // Reject empty, null/undefined, or strings with path traversal characters - if (!taskId || typeof taskId !== 'string') return false; - if (taskId.includes('/') || taskId.includes('\\')) return false; - if (taskId === '.' || taskId === '..') return false; - if (taskId.includes('\0')) return false; // Null byte injection - return true; - } - - /** - * Find ALL spec paths for a task, checking main directory and worktrees - * A task can exist in multiple locations (main + worktree), so return all paths - */ - private findAllSpecPaths(projectPath: string, specsBaseDir: string, taskId: string): string[] { - // Validate taskId to prevent path traversal - if (!this.isValidTaskId(taskId)) { - console.error(`[ProjectStore] findAllSpecPaths: Invalid taskId rejected: ${taskId}`); - return []; - } - - const paths: string[] = []; - - // 1. Check main specs directory - const mainSpecPath = path.join(projectPath, specsBaseDir, taskId); - if (existsSync(mainSpecPath)) { - paths.push(mainSpecPath); - } - - // 2. Check worktrees - const worktreesDir = getTaskWorktreeDir(projectPath); - if (existsSync(worktreesDir)) { - try { - const worktrees = readdirSync(worktreesDir, { withFileTypes: true }); - for (const worktree of worktrees) { - if (!worktree.isDirectory()) continue; - const worktreeSpecPath = path.join(worktreesDir, worktree.name, specsBaseDir, taskId); - if (existsSync(worktreeSpecPath)) { - paths.push(worktreeSpecPath); - } - } - } catch { - // Ignore errors reading worktrees - } - } - - return paths; - } - /** * Archive tasks by writing archivedAt to their metadata * @param projectId - Project ID @@ -743,7 +755,7 @@ export class ProjectStore { for (const taskId of taskIds) { // Find ALL locations where this task exists (main + worktrees) - const specPaths = this.findAllSpecPaths(project.path, specsBaseDir, taskId); + const specPaths = findAllSpecPaths(project.path, specsBaseDir, taskId); // If spec directory doesn't exist anywhere, skip gracefully if (specPaths.length === 0) { @@ -806,7 +818,7 @@ export class ProjectStore { for (const taskId of taskIds) { // Find ALL locations where this task exists (main + worktrees) - const specPaths = this.findAllSpecPaths(project.path, specsBaseDir, taskId); + const specPaths = findAllSpecPaths(project.path, specsBaseDir, taskId); if (specPaths.length === 0) { console.warn(`[ProjectStore] unarchiveTasks: Spec directory not found for task ${taskId}`); diff --git a/apps/frontend/src/main/utils/spec-path-helpers.ts b/apps/frontend/src/main/utils/spec-path-helpers.ts new file mode 100644 index 00000000..e84174d1 --- /dev/null +++ b/apps/frontend/src/main/utils/spec-path-helpers.ts @@ -0,0 +1,75 @@ +/** + * Shared utilities for spec path operations + * + * These functions are used by both project-store.ts and crud-handlers.ts + * to ensure consistent validation and path resolution. + */ +import path from 'path'; +import { existsSync, readdirSync } from 'fs'; +import { getTaskWorktreeDir } from '../worktree-paths'; + +/** + * Validate taskId to prevent path traversal attacks + * Returns true if taskId is safe to use in path operations + * + * @param taskId - The task ID to validate + * @returns true if the taskId is safe to use in path operations + */ +export function isValidTaskId(taskId: string): boolean { + // Reject empty, null/undefined, or strings with path traversal characters + if (!taskId || typeof taskId !== 'string') return false; + if (taskId.includes('/') || taskId.includes('\\')) return false; + if (taskId === '.' || taskId === '..') return false; + if (taskId.includes('\0')) return false; // Null byte injection + return true; +} + +/** + * Find ALL spec paths for a task, checking main directory and worktrees + * A task can exist in multiple locations (main + worktree), so return all paths + * + * @param projectPath - The root path of the project + * @param specsBaseDir - The relative path to specs directory (e.g., '.auto-claude/specs') + * @param taskId - The task/spec ID to find + * @param logPrefix - Optional prefix for log messages (defaults to '[SpecPathHelpers]') + * @returns Array of absolute paths where the spec exists + */ +export function findAllSpecPaths( + projectPath: string, + specsBaseDir: string, + taskId: string, + logPrefix: string = '[SpecPathHelpers]' +): string[] { + // Validate taskId to prevent path traversal + if (!isValidTaskId(taskId)) { + console.error(`${logPrefix} findAllSpecPaths: Invalid taskId rejected: ${taskId}`); + return []; + } + + const paths: string[] = []; + + // 1. Check main specs directory + const mainSpecPath = path.join(projectPath, specsBaseDir, taskId); + if (existsSync(mainSpecPath)) { + paths.push(mainSpecPath); + } + + // 2. Check worktrees + const worktreesDir = getTaskWorktreeDir(projectPath); + if (existsSync(worktreesDir)) { + try { + const worktrees = readdirSync(worktreesDir, { withFileTypes: true }); + for (const worktree of worktrees) { + if (!worktree.isDirectory()) continue; + const worktreeSpecPath = path.join(worktreesDir, worktree.name, specsBaseDir, taskId); + if (existsSync(worktreeSpecPath)) { + paths.push(worktreeSpecPath); + } + } + } catch { + // Ignore errors reading worktrees + } + } + + return paths; +} diff --git a/apps/frontend/src/preload/api/task-api.ts b/apps/frontend/src/preload/api/task-api.ts index 417b73f4..f00cbea2 100644 --- a/apps/frontend/src/preload/api/task-api.ts +++ b/apps/frontend/src/preload/api/task-api.ts @@ -19,7 +19,7 @@ import type { export interface TaskAPI { // Task Operations - getTasks: (projectId: string) => Promise>; + getTasks: (projectId: string, options?: { forceRefresh?: boolean }) => Promise>; createTask: ( projectId: string, title: string, @@ -85,8 +85,8 @@ export interface TaskAPI { export const createTaskAPI = (): TaskAPI => ({ // Task Operations - getTasks: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST, projectId), + getTasks: (projectId: string, options?: { forceRefresh?: boolean }): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST, projectId, options), createTask: ( projectId: string, diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 9ffcded4..a6d15af1 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -542,7 +542,9 @@ export function App() { if (!currentProjectId) return; setIsRefreshingTasks(true); try { - await loadTasks(currentProjectId); + // Pass forceRefresh: true to invalidate cache and get fresh data from disk + // This ensures the refresh button always shows the latest task state + await loadTasks(currentProjectId, { forceRefresh: true }); } finally { setIsRefreshingTasks(false); } diff --git a/apps/frontend/src/renderer/__tests__/task-order.test.ts b/apps/frontend/src/renderer/__tests__/task-order.test.ts index d6b0f1f4..b259b35c 100644 --- a/apps/frontend/src/renderer/__tests__/task-order.test.ts +++ b/apps/frontend/src/renderer/__tests__/task-order.test.ts @@ -32,6 +32,7 @@ function createTestTaskOrder(overrides: Partial = {}): TaskOrder human_review: [], pr_created: [], done: [], + error: [], ...overrides }; } @@ -249,7 +250,8 @@ describe('Task Order State Management', () => { ai_review: [], human_review: [], pr_created: [], - done: [] + done: [], + error: [] }); }); @@ -281,7 +283,8 @@ describe('Task Order State Management', () => { ai_review: [], human_review: [], pr_created: [], - done: [] + done: [], + error: [] }); expect(consoleSpy).toHaveBeenCalledWith('Failed to load task order:', expect.any(Error)); @@ -307,7 +310,8 @@ describe('Task Order State Management', () => { ai_review: [], human_review: [], pr_created: [], - done: [] + done: [], + error: [] }); localStorage.getItem = originalGetItem; @@ -495,7 +499,8 @@ describe('Task Order State Management', () => { ai_review: [], human_review: [], pr_created: [], - done: [] + done: [], + error: [] } as TaskOrderState; useTaskStore.setState({ taskOrder: order }); @@ -592,7 +597,8 @@ describe('Task Order State Management', () => { ai_review: [], human_review: [], pr_created: [], - done: [] + done: [], + error: [] }); consoleSpy.mockRestore(); diff --git a/apps/frontend/src/renderer/__tests__/task-store.test.ts b/apps/frontend/src/renderer/__tests__/task-store.test.ts index 9d3256e8..9ab11f7d 100644 --- a/apps/frontend/src/renderer/__tests__/task-store.test.ts +++ b/apps/frontend/src/renderer/__tests__/task-store.test.ts @@ -1346,5 +1346,589 @@ describe('Task Store', () => { expect(useTaskStore.getState().tasks[0].status).toBe('human_review'); }); }); + + // FIX (Subtask 4-2): Comprehensive tests for all active execution phases + describe('active execution phase protection - all phases', () => { + it('should NOT update status when task is in qa_review phase', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'in_progress', + executionProgress: { phase: 'qa_review', phaseProgress: 50, overallProgress: 80 } + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should remain in_progress during qa_review phase + expect(useTaskStore.getState().tasks[0].status).toBe('in_progress'); + }); + + it('should NOT update status when task is in qa_fixing phase', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'in_progress', + executionProgress: { phase: 'qa_fixing', phaseProgress: 30, overallProgress: 70 } + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should remain in_progress during qa_fixing phase + expect(useTaskStore.getState().tasks[0].status).toBe('in_progress'); + }); + + it('should still update subtasks when status recalculation is blocked', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'in_progress', + subtasks: [], + executionProgress: { phase: 'coding', phaseProgress: 50, overallProgress: 40 } + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'in_progress' }, + { id: 'c3', description: 'Subtask 3', status: 'pending' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should stay in_progress (blocked by active phase) + expect(useTaskStore.getState().tasks[0].status).toBe('in_progress'); + // But subtasks should still be updated + expect(useTaskStore.getState().tasks[0].subtasks).toHaveLength(3); + expect(useTaskStore.getState().tasks[0].subtasks[0].status).toBe('completed'); + expect(useTaskStore.getState().tasks[0].subtasks[1].status).toBe('in_progress'); + expect(useTaskStore.getState().tasks[0].subtasks[2].status).toBe('pending'); + }); + + it('should update title even when status recalculation is blocked', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + title: 'Original Title', + status: 'in_progress', + executionProgress: { phase: 'planning', phaseProgress: 50, overallProgress: 10 } + })] + }); + + const plan = createTestPlan({ + feature: 'New Feature Name', + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should stay in_progress (blocked by active phase) + expect(useTaskStore.getState().tasks[0].status).toBe('in_progress'); + // But title should still be updated + expect(useTaskStore.getState().tasks[0].title).toBe('New Feature Name'); + }); + }); + + // FIX (Subtask 4-2): Tests for shouldBlockTerminalTransition logic + describe('terminal transition blocking (shouldBlockTerminalTransition)', () => { + it('should block ai_review when subtasks array is empty', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'backlog', + subtasks: [], + executionProgress: undefined + })] + }); + + // Plan with empty subtasks should not trigger ai_review + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should remain backlog, not go to ai_review (no subtasks to complete) + expect(useTaskStore.getState().tasks[0].status).toBe('backlog'); + }); + + it('should allow transition to ai_review when all subtasks are completed', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'backlog', + subtasks: [], + executionProgress: undefined + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should transition to ai_review when all subtasks are completed + expect(useTaskStore.getState().tasks[0].status).toBe('ai_review'); + }); + + it('should allow transition to human_review when any subtask failed', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'in_progress', + subtasks: [], + executionProgress: undefined + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'failed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should transition to human_review when any subtask failed + expect(useTaskStore.getState().tasks[0].status).toBe('human_review'); + }); + + it('should transition to in_progress when some subtasks are in progress', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'backlog', + subtasks: [], + executionProgress: undefined + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'in_progress' }, + { id: 'c3', description: 'Subtask 3', status: 'pending' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should transition to in_progress when some subtasks are in progress + expect(useTaskStore.getState().tasks[0].status).toBe('in_progress'); + }); + + it('should transition to in_progress when only some subtasks are completed', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'backlog', + subtasks: [], + executionProgress: undefined + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'pending' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should transition to in_progress (some completed but not all) + expect(useTaskStore.getState().tasks[0].status).toBe('in_progress'); + }); + }); + + // FIX (Subtask 4-2): Combined guard tests + describe('combined status stability guards', () => { + it('should protect status when in terminal phase AND terminal status', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'done', + executionProgress: { phase: 'complete', phaseProgress: 100, overallProgress: 100 } + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'pending' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should remain done (protected by both terminal phase and terminal status) + expect(useTaskStore.getState().tasks[0].status).toBe('done'); + }); + + it('should protect status when pr_created even without terminal phase', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'pr_created', + executionProgress: { phase: 'idle', phaseProgress: 0, overallProgress: 0 } + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should remain pr_created (protected by terminal status) + expect(useTaskStore.getState().tasks[0].status).toBe('pr_created'); + }); + + it('should protect status in failed phase even with all subtasks completed', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'human_review', + executionProgress: { phase: 'failed', phaseProgress: 50, overallProgress: 30 } + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should remain human_review (protected by terminal phase 'failed') + expect(useTaskStore.getState().tasks[0].status).toBe('human_review'); + }); + + it('should NOT protect non-terminal status in non-active phase', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'in_progress', + executionProgress: { phase: 'idle', phaseProgress: 0, overallProgress: 0 } + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should change to ai_review (not protected) + expect(useTaskStore.getState().tasks[0].status).toBe('ai_review'); + }); + + it('should NOT update status from backlog to ai_review during active planning', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'backlog', + executionProgress: { phase: 'planning', phaseProgress: 10, overallProgress: 5 } + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should remain backlog (blocked by active planning phase) + expect(useTaskStore.getState().tasks[0].status).toBe('backlog'); + }); + }); + + // FIX (Subtask 4-2): Status stability edge cases + describe('status stability edge cases', () => { + it('should handle missing executionProgress gracefully', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'in_progress' + } as Partial)] + }); + + // Explicitly remove executionProgress + const task = useTaskStore.getState().tasks[0]; + delete (task as any).executionProgress; + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Should still recalculate status (no executionProgress = not in active phase) + expect(useTaskStore.getState().tasks[0].status).toBe('ai_review'); + }); + + it('should handle undefined phase in executionProgress', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'in_progress', + executionProgress: { phaseProgress: 0, overallProgress: 0 } as any + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Should recalculate status (undefined phase = not in active phase) + expect(useTaskStore.getState().tasks[0].status).toBe('ai_review'); + }); + + it('should preserve reviewReason when status changes to human_review', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'in_progress', + reviewReason: undefined + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'failed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should change to human_review with errors reason + expect(useTaskStore.getState().tasks[0].status).toBe('human_review'); + expect(useTaskStore.getState().tasks[0].reviewReason).toBe('errors'); + }); + + it('should update reviewReason when task is already in human_review and plan has failures', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'human_review', + reviewReason: 'qa_rejected' + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'failed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should remain human_review (terminal status in terminalStatuses) + expect(useTaskStore.getState().tasks[0].status).toBe('human_review'); + // reviewReason should be updated to reflect the current failure state from the plan + // This is intentional - the plan's failure state takes precedence to show current state + expect(useTaskStore.getState().tasks[0].reviewReason).toBe('errors'); + }); + + it('should preserve reviewReason when task is in terminal status with no failures', () => { + useTaskStore.setState({ + tasks: [createTestTask({ + id: 'task-1', + status: 'human_review', + reviewReason: 'completed' + })] + }); + + const plan = createTestPlan({ + phases: [ + { + phase: 1, + name: 'Phase 1', + type: 'implementation', + subtasks: [ + { id: 'c1', description: 'Subtask 1', status: 'completed' }, + { id: 'c2', description: 'Subtask 2', status: 'completed' } + ] + } + ] + }); + + useTaskStore.getState().updateTaskFromPlan('task-1', plan); + + // Status should remain human_review (protected by terminalStatuses check) + expect(useTaskStore.getState().tasks[0].status).toBe('human_review'); + // reviewReason should be preserved since allCompleted branch is also blocked + expect(useTaskStore.getState().tasks[0].reviewReason).toBe('completed'); + }); + }); }); }); diff --git a/apps/frontend/src/renderer/components/KanbanBoard.tsx b/apps/frontend/src/renderer/components/KanbanBoard.tsx index 9df23530..2f22b53b 100644 --- a/apps/frontend/src/renderer/components/KanbanBoard.tsx +++ b/apps/frontend/src/renderer/components/KanbanBoard.tsx @@ -43,10 +43,13 @@ function isValidDropColumn(id: string): id is typeof TASK_STATUS_COLUMNS[number] /** * Get the visual column for a task status. * pr_created tasks are displayed in the 'done' column, so we map them accordingly. + * error tasks are displayed in the 'human_review' column (errors need human attention). * This is used to compare visual positions during drag-and-drop operations. */ function getVisualColumn(status: TaskStatus): typeof TASK_STATUS_COLUMNS[number] { - return status === 'pr_created' ? 'done' : status; + if (status === 'pr_created') return 'done'; + if (status === 'error') return 'human_review'; + return status; } interface KanbanBoardProps { @@ -478,6 +481,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR const tasksByStatus = useMemo(() => { // Note: pr_created tasks are shown in the 'done' column since they're essentially complete + // Note: error tasks are shown in the 'human_review' column since they need human attention const grouped: Record = { backlog: [], in_progress: [], @@ -487,8 +491,8 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR }; filteredTasks.forEach((task) => { - // Map pr_created tasks to the done column - const targetColumn = task.status === 'pr_created' ? 'done' : task.status; + // Map pr_created tasks to the done column, error tasks to human_review + const targetColumn = getVisualColumn(task.status); if (grouped[targetColumn]) { grouped[targetColumn].push(task); } @@ -741,7 +745,8 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR ai_review: [], human_review: [], pr_created: [], - done: [] + done: [], + error: [] }; for (const status of Object.keys(taskOrder) as Array) { diff --git a/apps/frontend/src/renderer/stores/task-store.ts b/apps/frontend/src/renderer/stores/task-store.ts index 9a02ed24..59cb1a2b 100644 --- a/apps/frontend/src/renderer/stores/task-store.ts +++ b/apps/frontend/src/renderer/stores/task-store.ts @@ -124,7 +124,8 @@ function createEmptyTaskOrder(): TaskOrderState { ai_review: [], human_review: [], pr_created: [], - done: [] + done: [], + error: [] }; } @@ -180,13 +181,20 @@ export const useTaskStore = create((set, get) => ({ updateTaskStatus: (taskId, status) => set((state) => { const index = findTaskIndex(state.tasks, taskId); - if (index === -1) return state; + if (index === -1) { + debugLog('[updateTaskStatus] Task not found:', taskId); + return state; + } return { tasks: updateTaskAtIndex(state.tasks, index, (t) => { // Determine execution progress based on status transition let executionProgress = t.executionProgress; + // Track status transition for debugging flip-flop issues + const previousStatus = t.status; + const statusChanged = previousStatus !== status; + if (status === 'backlog') { // When status goes to backlog, reset execution progress to idle // This ensures the planning/coding animation stops when task is stopped @@ -197,6 +205,16 @@ export const useTaskStore = create((set, get) => ({ executionProgress = { phase: 'planning' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 }; } + // Log status transitions to help diagnose flip-flop issues + debugLog('[updateTaskStatus] Status transition:', { + taskId, + previousStatus, + newStatus: status, + statusChanged, + currentPhase: t.executionProgress?.phase, + newPhase: executionProgress?.phase + }); + return { ...t, status, executionProgress, updatedAt: new Date() }; }) }; @@ -272,12 +290,21 @@ export const useTaskStore = create((set, get) => ({ let reviewReason: ReviewReason | undefined = t.reviewReason; // RACE CONDITION FIX: Don't let stale plan data override status during active execution + // Strengthen guard: ANY active phase means NO status recalculation from plan data const activePhases: ExecutionPhase[] = ['planning', 'coding', 'qa_review', 'qa_fixing']; - const isInActivePhase = t.executionProgress?.phase && activePhases.includes(t.executionProgress.phase); + const isInActivePhase = Boolean(t.executionProgress?.phase && activePhases.includes(t.executionProgress.phase)); // FIX (Flip-Flop Bug): Terminal phases should NOT trigger status recalculation // When phase is 'complete' or 'failed', the task has finished and status should be stable - const isInTerminalPhase = t.executionProgress?.phase && isTerminalPhase(t.executionProgress.phase); + const isInTerminalPhase = Boolean(t.executionProgress?.phase && isTerminalPhase(t.executionProgress.phase)); + + // FIX (Subtask 2-1): Terminal task statuses should NEVER be recalculated from plan data + // pr_created, done, and error are finalized workflow states set by explicit user/system actions + // Once a task reaches these statuses, they should only change via explicit user actions (like drag-drop) + // This prevents stale plan file reads from incorrectly downgrading completed tasks + // NOTE: Keep this in sync with TERMINAL_STATUSES in project-store.ts + const TERMINAL_TASK_STATUSES: TaskStatus[] = ['pr_created', 'done', 'error']; + const isInTerminalStatus = TERMINAL_TASK_STATUSES.includes(t.status); // FIX (Flip-Flop Bug): Respect explicit human_review status from plan file // When the plan explicitly says 'human_review', don't override it with calculated status @@ -317,9 +344,10 @@ export const useTaskStore = create((set, get) => ({ // Only recalculate status if: // 1. NOT in an active execution phase (planning, coding, qa_review, qa_fixing) // 2. NOT in a terminal phase (complete, failed) - status should be stable - // 3. Plan doesn't explicitly say human_review - // 4. Would not create an invalid terminal transition (ACS-203) - if (!isInActivePhase && !isInTerminalPhase && !isExplicitHumanReview) { + // 3. NOT in a terminal task status (pr_created, done) - finalized workflow states + // 4. Plan doesn't explicitly say human_review + // 5. Would not create an invalid terminal transition (ACS-203) + if (!isInActivePhase && !isInTerminalPhase && !isInTerminalStatus && !isExplicitHumanReview) { if (allCompleted && hasSubtasks) { // FIX (Flip-Flop Bug): Don't downgrade from terminal statuses to ai_review // Once a task reaches human_review, pr_created, or done, it should stay there @@ -360,6 +388,7 @@ export const useTaskStore = create((set, get) => ({ newStatus: status, isInActivePhase, isInTerminalPhase, + isInTerminalStatus, isExplicitHumanReview, planStatus, currentPhase: t.executionProgress?.phase, @@ -540,7 +569,8 @@ export const useTaskStore = create((set, get) => ({ ai_review: isValidColumnArray(parsed.ai_review) ? parsed.ai_review : emptyOrder.ai_review, human_review: isValidColumnArray(parsed.human_review) ? parsed.human_review : emptyOrder.human_review, pr_created: isValidColumnArray(parsed.pr_created) ? parsed.pr_created : emptyOrder.pr_created, - done: isValidColumnArray(parsed.done) ? parsed.done : emptyOrder.done + done: isValidColumnArray(parsed.done) ? parsed.done : emptyOrder.done, + error: isValidColumnArray(parsed.error) ? parsed.error : emptyOrder.error }; set({ taskOrder: validatedOrder }); @@ -593,14 +623,17 @@ export const useTaskStore = create((set, get) => ({ /** * Load tasks for a project + * @param projectId - The project ID to load tasks for + * @param options - Optional parameters + * @param options.forceRefresh - If true, invalidates server-side cache before fetching (for refresh button) */ -export async function loadTasks(projectId: string): Promise { +export async function loadTasks(projectId: string, options?: { forceRefresh?: boolean }): Promise { const store = useTaskStore.getState(); store.setLoading(true); store.setError(null); try { - const result = await window.electronAPI.getTasks(projectId); + const result = await window.electronAPI.getTasks(projectId, options); if (result.success && result.data) { store.setTasks(result.data); } else { diff --git a/apps/frontend/src/shared/constants/task.ts b/apps/frontend/src/shared/constants/task.ts index b273585d..c0a9f0f5 100644 --- a/apps/frontend/src/shared/constants/task.ts +++ b/apps/frontend/src/shared/constants/task.ts @@ -20,24 +20,28 @@ export type TaskStatusColumn = typeof TASK_STATUS_COLUMNS[number]; // Status label translation keys (use with t() from react-i18next) // Note: pr_created maps to 'done' column in Kanban view (see KanbanBoard.tsx) -export const TASK_STATUS_LABELS: Record = { +// Note: error maps to 'human_review' column in Kanban view (errors need human attention) +export const TASK_STATUS_LABELS: Record = { backlog: 'columns.backlog', in_progress: 'columns.in_progress', ai_review: 'columns.ai_review', human_review: 'columns.human_review', done: 'columns.done', - pr_created: 'columns.pr_created' + pr_created: 'columns.pr_created', + error: 'columns.error' }; // Status colors for UI // Note: pr_created maps to 'done' column in Kanban view (see KanbanBoard.tsx) -export const TASK_STATUS_COLORS: Record = { +// Note: error maps to 'human_review' column in Kanban view (errors need human attention) +export const TASK_STATUS_COLORS: Record = { backlog: 'bg-muted text-muted-foreground', in_progress: 'bg-info/10 text-info', ai_review: 'bg-warning/10 text-warning', human_review: 'bg-purple-500/10 text-purple-400', done: 'bg-success/10 text-success', - pr_created: 'bg-info/10 text-info' + pr_created: 'bg-info/10 text-info', + error: 'bg-destructive/10 text-destructive' }; // ============================================ diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index 1be09f76..f11e15cb 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -59,7 +59,8 @@ "ai_review": "AI Review", "human_review": "Human Review", "done": "Done", - "pr_created": "PR Created" + "pr_created": "PR Created", + "error": "Error" }, "kanban": { "emptyBacklog": "No tasks planned", diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index 74d9a967..e589147e 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -59,7 +59,8 @@ "ai_review": "Révision IA", "human_review": "Révision humaine", "done": "Terminé", - "pr_created": "PR créée" + "pr_created": "PR créée", + "error": "Erreur" }, "kanban": { "emptyBacklog": "Aucune tâche planifiée", diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 689422ad..557a3ef7 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -156,7 +156,7 @@ export interface ElectronAPI { saveTabState: (tabState: TabState) => Promise; // Task operations - getTasks: (projectId: string) => Promise>; + getTasks: (projectId: string, options?: { forceRefresh?: boolean }) => Promise>; createTask: (projectId: string, title: string, description: string, metadata?: TaskMetadata) => Promise>; deleteTask: (taskId: string) => Promise; updateTask: (taskId: string, updates: { title?: string; description?: string }) => Promise>; diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index 30e0fb19..d7cce8c6 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -5,7 +5,7 @@ import type { ThinkingLevel, PhaseModelConfig, PhaseThinkingConfig } from './settings'; import type { ExecutionPhase as ExecutionPhaseType, CompletablePhase } from '../constants/phase-protocol'; -export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done'; +export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'pr_created' | 'done' | 'error'; // Maps task status columns to ordered task IDs for kanban board reordering export type TaskOrderState = Record; diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..561d7941 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +../../../../node_modules \ No newline at end of file