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 c4f78c2f75..97e6c190b6 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -9,6 +9,7 @@ import { fileWatcher } from '../../file-watcher'; import { findTaskAndProject } from './shared'; import { checkGitStatus } from '../../project-initializer'; import { getClaudeProfileManager } from '../../claude-profile-manager'; +import { taskLogService } from '../../task-log-service'; /** * Helper function to check subtask completion status @@ -185,6 +186,20 @@ export function registerTaskExecutionHandlers( agentManager.killTask(taskId); fileWatcher.unwatch(taskId); + // Update phase logs to mark active phases as stopped + // Wrapped in try-catch to ensure task stopping still works even if log update fails + try { + const { task, project } = findTaskAndProject(taskId); + if (task && project) { + // Mark any active phases as stopped in the log files + const specsBaseDir = getSpecsDir(project.autoBuildPath); + taskLogService.markActivePhasesStopped(task.specId, project.path, specsBaseDir); + } + } catch (error) { + // Log error but don't prevent task from stopping + console.error('[TASK_STOP] Failed to update phase logs:', error); + } + const mainWindow = getMainWindow(); if (mainWindow) { mainWindow.webContents.send( diff --git a/apps/frontend/src/main/task-log-service.ts b/apps/frontend/src/main/task-log-service.ts index 9ad2569649..16bfedb377 100644 --- a/apps/frontend/src/main/task-log-service.ts +++ b/apps/frontend/src/main/task-log-service.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { existsSync, readFileSync, watchFile } from 'fs'; +import { existsSync, readFileSync, writeFileSync, watchFile, renameSync, unlinkSync } from 'fs'; import { EventEmitter } from 'events'; import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types'; @@ -376,6 +376,106 @@ export class TaskLogService extends EventEmitter { const logFile = path.join(specDir, 'task_logs.json'); return existsSync(logFile); } + + /** + * Mark all active phases as stopped when a task is interrupted + * Updates both main and worktree log files if they exist + * + * @param specId - The spec ID + * @param projectPath - Project root path (needed to find worktree) + * @param specsRelPath - Relative path to specs (e.g., ".auto-claude/specs") + */ + markActivePhasesStopped(specId: string, projectPath: string, specsRelPath: string): void { + // Validate specId to prevent path traversal attacks + if (!this.isValidSpecId(specId)) { + console.error(`[TaskLogService] Invalid specId format: ${specId}`); + return; + } + + // Get watched paths for this spec + const watchedInfo = this.watchedPaths.get(specId); + + // Build paths for main and worktree spec directories + const mainSpecDir = watchedInfo?.mainSpecDir || path.join(projectPath, specsRelPath, specId); + const worktreeSpecDir = watchedInfo?.worktreeSpecDir || path.join(projectPath, '.worktrees', specId, specsRelPath, specId); + + // Update main spec dir logs + this.updateLogFilePhaseStatus(path.join(mainSpecDir, 'task_logs.json')); + + // Update worktree spec dir logs if it exists + if (existsSync(worktreeSpecDir)) { + this.updateLogFilePhaseStatus(path.join(worktreeSpecDir, 'task_logs.json')); + } + + // Reload and emit updated logs + const updatedLogs = this.loadLogs(mainSpecDir, projectPath, specsRelPath, specId); + if (updatedLogs) { + this.logCache.set(mainSpecDir, updatedLogs); + this.emit('logs-changed', specId, updatedLogs); + } + } + + /** + * Update a single log file to mark active phases as stopped + * Uses atomic write (temp file + rename) to prevent corruption + */ + private updateLogFilePhaseStatus(logFilePath: string): void { + try { + // Read file - if it doesn't exist, readFileSync will throw and we'll catch it + const content = readFileSync(logFilePath, 'utf-8'); + const logs = JSON.parse(content) as TaskLogs; + + let hasChanges = false; + const phases: TaskLogPhase[] = ['planning', 'coding', 'validation']; + + for (const phase of phases) { + if (logs.phases[phase]?.status === 'active') { + // Mark as stopped since the task was stopped by the user + logs.phases[phase].status = 'stopped'; + logs.phases[phase].completed_at = new Date().toISOString(); + hasChanges = true; + } + } + + // Only write if we made changes + if (hasChanges) { + logs.updated_at = new Date().toISOString(); + + // Use atomic write: temp file + rename to prevent corruption + const tempFilePath = `${logFilePath}.tmp`; + try { + writeFileSync(tempFilePath, JSON.stringify(logs, null, 2), 'utf-8'); + renameSync(tempFilePath, logFilePath); + console.log(`[TaskLogService] Marked active phases as stopped in ${logFilePath}`); + } catch (writeError) { + // Clean up temp file if it exists + if (existsSync(tempFilePath)) { + unlinkSync(tempFilePath); + } + throw writeError; + } + } + } catch (error) { + // File doesn't exist, is corrupted, or write failed - log and continue + console.error(`[TaskLogService] Failed to update log file ${logFilePath}:`, error); + } + } + + /** + * Validate specId format to prevent path traversal attacks + * Valid format: 3 digits followed by dash and alphanumeric characters with dashes + * Example: "001-feature-name" or "123-bug-fix" + */ + private isValidSpecId(specId: string): boolean { + // Check for path traversal patterns + if (specId.includes('..') || specId.includes('/') || specId.includes('\\')) { + return false; + } + + // Validate format: XXX-name (e.g., 001-feature, 123-bug-fix) + const specIdPattern = /^[0-9]{3}-[a-z0-9-]+$/; + return specIdPattern.test(specId); + } } // Singleton instance diff --git a/apps/frontend/src/renderer/components/task-detail/TaskLogs.tsx b/apps/frontend/src/renderer/components/task-detail/TaskLogs.tsx index 3481b263f0..ce46c0dc12 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskLogs.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskLogs.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Terminal, Loader2, @@ -175,6 +176,7 @@ interface PhaseLogSectionProps { } function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck, phaseConfig }: PhaseLogSectionProps) { + const { t } = useTranslation('taskLogs'); const Icon = PHASE_ICONS[phase]; const status = phaseLog?.status || 'pending'; const hasEntries = (phaseLog?.entries.length || 0) > 0; @@ -186,34 +188,41 @@ function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck, p return ( - Interrupted + {t('status.interrupted')} ); } return ( - Running + {t('status.active')} ); case 'completed': return ( - Complete + {t('status.completed')} ); case 'failed': return ( - Failed + {t('status.failed')} + + ); + case 'stopped': + return ( + + + {t('status.stopped')} ); default: return ( - Pending + {t('status.pending')} ); } @@ -232,6 +241,7 @@ function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck, p isInterrupted && 'border-warning/30 bg-warning/5', status === 'completed' && 'border-success/30 bg-success/5', status === 'failed' && 'border-destructive/30 bg-destructive/5', + status === 'stopped' && 'border-muted-foreground/30 bg-muted/5', status === 'pending' && 'border-border bg-secondary/30' )} > diff --git a/apps/frontend/src/shared/i18n/index.ts b/apps/frontend/src/shared/i18n/index.ts index 407d8d4c75..a3a6283142 100644 --- a/apps/frontend/src/shared/i18n/index.ts +++ b/apps/frontend/src/shared/i18n/index.ts @@ -10,6 +10,7 @@ import enWelcome from './locales/en/welcome.json'; import enOnboarding from './locales/en/onboarding.json'; import enDialogs from './locales/en/dialogs.json'; import enTaskReview from './locales/en/taskReview.json'; +import enTaskLogs from './locales/en/taskLogs.json'; // Import French translation resources import frCommon from './locales/fr/common.json'; @@ -20,6 +21,7 @@ import frWelcome from './locales/fr/welcome.json'; import frOnboarding from './locales/fr/onboarding.json'; import frDialogs from './locales/fr/dialogs.json'; import frTaskReview from './locales/fr/taskReview.json'; +import frTaskLogs from './locales/fr/taskLogs.json'; export const defaultNS = 'common'; @@ -32,7 +34,8 @@ export const resources = { welcome: enWelcome, onboarding: enOnboarding, dialogs: enDialogs, - taskReview: enTaskReview + taskReview: enTaskReview, + taskLogs: enTaskLogs }, fr: { common: frCommon, @@ -42,7 +45,8 @@ export const resources = { welcome: frWelcome, onboarding: frOnboarding, dialogs: frDialogs, - taskReview: frTaskReview + taskReview: frTaskReview, + taskLogs: frTaskLogs } } as const; @@ -53,7 +57,7 @@ i18n lng: 'en', // Default language (will be overridden by settings) fallbackLng: 'en', defaultNS, - ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'taskReview'], + ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'taskReview', 'taskLogs'], interpolation: { escapeValue: false // React already escapes values }, diff --git a/apps/frontend/src/shared/i18n/locales/en/taskLogs.json b/apps/frontend/src/shared/i18n/locales/en/taskLogs.json new file mode 100644 index 0000000000..bd005772af --- /dev/null +++ b/apps/frontend/src/shared/i18n/locales/en/taskLogs.json @@ -0,0 +1,10 @@ +{ + "status": { + "pending": "Pending", + "active": "Running", + "completed": "Complete", + "failed": "Failed", + "stopped": "Stopped", + "interrupted": "Interrupted" + } +} diff --git a/apps/frontend/src/shared/i18n/locales/fr/taskLogs.json b/apps/frontend/src/shared/i18n/locales/fr/taskLogs.json new file mode 100644 index 0000000000..72dc9840b7 --- /dev/null +++ b/apps/frontend/src/shared/i18n/locales/fr/taskLogs.json @@ -0,0 +1,10 @@ +{ + "status": { + "pending": "En attente", + "active": "En cours", + "completed": "Terminé", + "failed": "Échoué", + "stopped": "Arrêté", + "interrupted": "Interrompu" + } +} diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index 42c9e61a61..eb0c681c86 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -58,7 +58,7 @@ export interface QAIssue { // Task Log Types - for persistent, phase-based logging export type TaskLogPhase = 'planning' | 'coding' | 'validation'; -export type TaskLogPhaseStatus = 'pending' | 'active' | 'completed' | 'failed'; +export type TaskLogPhaseStatus = 'pending' | 'active' | 'completed' | 'failed' | 'stopped'; export type TaskLogEntryType = 'text' | 'tool_start' | 'tool_end' | 'phase_start' | 'phase_end' | 'error' | 'success' | 'info'; export interface TaskLogEntry {