| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import path from 'path'; |
| import type { Feature, FeatureStatusWithPipeline, ParsedTask, PlanSpec } from '@automaker/types'; |
| import { isPipelineStatus } from '@automaker/types'; |
| import { |
| atomicWriteJson, |
| readJsonWithRecovery, |
| logRecoveryWarning, |
| DEFAULT_BACKUP_COUNT, |
| createLogger, |
| } from '@automaker/utils'; |
| import { getFeatureDir, getFeaturesDir } from '@automaker/platform'; |
| import * as secureFs from '../lib/secure-fs.js'; |
| import type { EventEmitter } from '../lib/events.js'; |
| import type { AutoModeEventType } from './typed-event-bus.js'; |
| import { getNotificationService } from './notification-service.js'; |
| import { FeatureLoader } from './feature-loader.js'; |
| import { pipelineService } from './pipeline-service.js'; |
|
|
| const logger = createLogger('FeatureStateManager'); |
|
|
| |
| const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval'; |
| const NOTIFICATION_TYPE_VERIFIED = 'feature_verified'; |
| const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error'; |
| const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error'; |
|
|
| |
| const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review'; |
| const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified'; |
| const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed'; |
| const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error'; |
|
|
| |
| |
| |
| |
| interface AutoModeEventPayload { |
| type?: string; |
| featureId?: string; |
| featureName?: string; |
| passes?: boolean; |
| executionMode?: 'auto' | 'manual'; |
| message?: string; |
| error?: string; |
| errorType?: string; |
| projectPath?: string; |
| |
| status?: string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export class FeatureStateManager { |
| private events: EventEmitter; |
| private featureLoader: FeatureLoader; |
| private unsubscribe: (() => void) | null = null; |
|
|
| constructor(events: EventEmitter, featureLoader: FeatureLoader) { |
| this.events = events; |
| this.featureLoader = featureLoader; |
|
|
| |
| this.unsubscribe = events.subscribe((type, payload) => { |
| if (type === 'auto-mode:event') { |
| this.handleAutoModeEventError(payload as AutoModeEventPayload); |
| } |
| }); |
| } |
|
|
| |
| |
| |
| destroy(): void { |
| if (this.unsubscribe) { |
| this.unsubscribe(); |
| this.unsubscribe = null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> { |
| const featureDir = getFeatureDir(projectPath, featureId); |
| const featurePath = path.join(featureDir, 'feature.json'); |
|
|
| try { |
| const result = await readJsonWithRecovery<Feature | null>(featurePath, null, { |
| maxBackups: DEFAULT_BACKUP_COUNT, |
| autoRestore: true, |
| }); |
| logRecoveryWarning(result, `Feature ${featureId}`, logger); |
| return result.data; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async updateFeatureStatus(projectPath: string, featureId: string, status: string): Promise<void> { |
| const featureDir = getFeatureDir(projectPath, featureId); |
| const featurePath = path.join(featureDir, 'feature.json'); |
|
|
| try { |
| |
| const result = await readJsonWithRecovery<Feature | null>(featurePath, null, { |
| maxBackups: DEFAULT_BACKUP_COUNT, |
| autoRestore: true, |
| }); |
|
|
| logRecoveryWarning(result, `Feature ${featureId}`, logger); |
|
|
| const feature = result.data; |
| if (!feature) { |
| logger.warn(`Feature ${featureId} not found or could not be recovered`); |
| return; |
| } |
|
|
| feature.status = status; |
| feature.updatedAt = new Date().toISOString(); |
|
|
| |
| const shouldSetJustFinishedAt = status === 'waiting_approval'; |
| const shouldClearJustFinishedAt = status !== 'waiting_approval'; |
| if (shouldSetJustFinishedAt) { |
| feature.justFinishedAt = new Date().toISOString(); |
| } else if (shouldClearJustFinishedAt) { |
| feature.justFinishedAt = undefined; |
| } |
|
|
| |
| if (status === 'waiting_approval' || status === 'verified') { |
| this.finalizeInProgressTasks(feature, featureId, status); |
| } |
|
|
| |
| await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); |
|
|
| |
| this.emitAutoModeEvent('feature_status_changed', { |
| featureId, |
| projectPath, |
| status, |
| }); |
|
|
| |
| |
| try { |
| const notificationService = getNotificationService(); |
| const displayName = this.getFeatureDisplayName(feature, featureId); |
|
|
| if (status === 'waiting_approval') { |
| await notificationService.createNotification({ |
| type: NOTIFICATION_TYPE_WAITING_APPROVAL, |
| title: displayName, |
| message: NOTIFICATION_TITLE_WAITING_APPROVAL, |
| featureId, |
| projectPath, |
| }); |
| } else if (status === 'verified') { |
| await notificationService.createNotification({ |
| type: NOTIFICATION_TYPE_VERIFIED, |
| title: displayName, |
| message: NOTIFICATION_TITLE_VERIFIED, |
| featureId, |
| projectPath, |
| }); |
| } |
| } catch (notificationError) { |
| logger.warn(`Failed to create notification for feature ${featureId}:`, notificationError); |
| } |
|
|
| |
| if (status === 'verified' || status === 'completed') { |
| try { |
| await this.featureLoader.syncFeatureToAppSpec(projectPath, feature); |
| } catch (syncError) { |
| |
| logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError); |
| } |
| } |
| } catch (error) { |
| logger.error(`Failed to update feature status for ${featureId}:`, error); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async markFeatureInterrupted( |
| projectPath: string, |
| featureId: string, |
| reason?: string |
| ): Promise<void> { |
| |
| const feature = await this.loadFeature(projectPath, featureId); |
| const currentStatus = feature?.status; |
|
|
| |
| if (isPipelineStatus(currentStatus)) { |
| logger.info( |
| `Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume` |
| ); |
| return; |
| } |
|
|
| if (reason) { |
| logger.info(`Marking feature ${featureId} as interrupted: ${reason}`); |
| } else { |
| logger.info(`Marking feature ${featureId} as interrupted`); |
| } |
|
|
| await this.updateFeatureStatus(projectPath, featureId, 'interrupted'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private async scanAndResetFeatures( |
| projectPath: string, |
| callerLabel: string |
| ): Promise<{ |
| reconciledFeatures: Array<{ |
| id: string; |
| previousStatus: string | undefined; |
| newStatus: string | undefined; |
| }>; |
| reconciledFeatureIds: string[]; |
| reconciledCount: number; |
| scanned: number; |
| }> { |
| const featuresDir = getFeaturesDir(projectPath); |
| let scanned = 0; |
| let reconciledCount = 0; |
| const reconciledFeatureIds: string[] = []; |
| const reconciledFeatures: Array<{ |
| id: string; |
| previousStatus: string | undefined; |
| newStatus: string | undefined; |
| }> = []; |
|
|
| try { |
| const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); |
|
|
| for (const entry of entries) { |
| if (!entry.isDirectory()) continue; |
|
|
| scanned++; |
| const featurePath = path.join(featuresDir, entry.name, 'feature.json'); |
| const result = await readJsonWithRecovery<Feature | null>(featurePath, null, { |
| maxBackups: DEFAULT_BACKUP_COUNT, |
| autoRestore: true, |
| }); |
|
|
| const feature = result.data; |
| if (!feature) continue; |
|
|
| let needsUpdate = false; |
| const originalStatus = feature.status; |
|
|
| |
| |
| const isActiveState = originalStatus === 'in_progress' || originalStatus === 'interrupted'; |
|
|
| if (isActiveState) { |
| const hasApprovedPlan = feature.planSpec?.status === 'approved'; |
| feature.status = hasApprovedPlan ? 'ready' : 'backlog'; |
| needsUpdate = true; |
| logger.info( |
| `[${callerLabel}] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}` |
| ); |
| } |
|
|
| |
| |
| if (isPipelineStatus(originalStatus)) { |
| |
| |
| |
| logger.debug( |
| `[${callerLabel}] Preserving pipeline status for feature ${feature.id}: ${originalStatus}` |
| ); |
| } |
|
|
| |
| if (feature.planSpec?.status === 'generating') { |
| feature.planSpec.status = 'pending'; |
| needsUpdate = true; |
| logger.info( |
| `[${callerLabel}] Reset feature ${feature.id} planSpec status from generating to pending` |
| ); |
| } |
|
|
| |
| if (feature.planSpec?.tasks) { |
| for (const task of feature.planSpec.tasks) { |
| if (task.status === 'in_progress') { |
| task.status = 'pending'; |
| needsUpdate = true; |
| logger.info( |
| `[${callerLabel}] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` |
| ); |
| |
| if (feature.planSpec?.currentTaskId === task.id) { |
| feature.planSpec.currentTaskId = undefined; |
| logger.info( |
| `[${callerLabel}] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})` |
| ); |
| } |
| } |
| } |
| } |
|
|
| if (needsUpdate) { |
| feature.updatedAt = new Date().toISOString(); |
| await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); |
| reconciledCount++; |
| reconciledFeatureIds.push(feature.id); |
| reconciledFeatures.push({ |
| id: feature.id, |
| previousStatus: originalStatus, |
| newStatus: feature.status, |
| }); |
| } |
| } |
| } catch (error) { |
| |
| if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { |
| logger.error(`[${callerLabel}] Error resetting features for ${projectPath}:`, error); |
| } |
| } |
|
|
| return { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async resetStuckFeatures(projectPath: string): Promise<void> { |
| const { reconciledCount, scanned } = await this.scanAndResetFeatures( |
| projectPath, |
| 'resetStuckFeatures' |
| ); |
|
|
| logger.info( |
| `[resetStuckFeatures] Scanned ${scanned} features, reset ${reconciledCount} features for ${projectPath}` |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async reconcileAllFeatureStates(projectPath: string): Promise<number> { |
| logger.info(`[reconcileAllFeatureStates] Starting reconciliation for ${projectPath}`); |
|
|
| const { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned } = |
| await this.scanAndResetFeatures(projectPath, 'reconcileAllFeatureStates'); |
|
|
| |
| for (const { id, previousStatus, newStatus } of reconciledFeatures) { |
| this.emitAutoModeEvent('feature_status_changed', { |
| featureId: id, |
| projectPath, |
| status: newStatus, |
| previousStatus, |
| reason: 'server_restart_reconciliation', |
| }); |
| } |
|
|
| |
| if (reconciledCount > 0) { |
| this.emitAutoModeEvent('features_reconciled', { |
| projectPath, |
| reconciledCount, |
| reconciledFeatureIds, |
| message: `Reconciled ${reconciledCount} feature(s) after server restart`, |
| }); |
| } |
|
|
| logger.info( |
| `[reconcileAllFeatureStates] Scanned ${scanned} features, reconciled ${reconciledCount} for ${projectPath}` |
| ); |
|
|
| return reconciledCount; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async updateFeaturePlanSpec( |
| projectPath: string, |
| featureId: string, |
| updates: Partial<PlanSpec> |
| ): Promise<void> { |
| const featureDir = getFeatureDir(projectPath, featureId); |
| const featurePath = path.join(featureDir, 'feature.json'); |
|
|
| try { |
| const result = await readJsonWithRecovery<Feature | null>(featurePath, null, { |
| maxBackups: DEFAULT_BACKUP_COUNT, |
| autoRestore: true, |
| }); |
|
|
| logRecoveryWarning(result, `Feature ${featureId}`, logger); |
|
|
| const feature = result.data; |
| if (!feature) { |
| logger.warn(`Feature ${featureId} not found or could not be recovered`); |
| return; |
| } |
|
|
| |
| if (!feature.planSpec) { |
| feature.planSpec = { |
| status: 'pending', |
| version: 1, |
| reviewedByUser: false, |
| }; |
| } |
|
|
| |
| const oldContent = feature.planSpec.content; |
|
|
| |
| Object.assign(feature.planSpec, updates); |
|
|
| |
| if (updates.content !== undefined && updates.content !== oldContent) { |
| feature.planSpec.version = (feature.planSpec.version || 0) + 1; |
| } |
|
|
| feature.updatedAt = new Date().toISOString(); |
|
|
| |
| await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); |
|
|
| |
| this.emitAutoModeEvent('plan_spec_updated', { |
| featureId, |
| projectPath, |
| planSpec: feature.planSpec, |
| }); |
| } catch (error) { |
| logger.error(`Failed to update planSpec for ${featureId}:`, error); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> { |
| const featureDir = getFeatureDir(projectPath, featureId); |
| const featurePath = path.join(featureDir, 'feature.json'); |
| const normalizedSummary = summary.trim(); |
|
|
| try { |
| const result = await readJsonWithRecovery<Feature | null>(featurePath, null, { |
| maxBackups: DEFAULT_BACKUP_COUNT, |
| autoRestore: true, |
| }); |
|
|
| logRecoveryWarning(result, `Feature ${featureId}`, logger); |
|
|
| const feature = result.data; |
| if (!feature) { |
| logger.warn(`Feature ${featureId} not found or could not be recovered`); |
| return; |
| } |
|
|
| if (!normalizedSummary) { |
| logger.debug( |
| `[saveFeatureSummary] Skipping empty summary for feature ${featureId} (status="${feature.status}")` |
| ); |
| return; |
| } |
|
|
| |
| if (isPipelineStatus(feature.status)) { |
| |
| |
| |
| const implementationHeader = '### Implementation'; |
| if (feature.summary && !feature.summary.trimStart().startsWith('### ')) { |
| feature.summary = `${implementationHeader}\n\n${feature.summary.trim()}`; |
| } |
|
|
| const stepName = await this.getPipelineStepName(projectPath, feature.status); |
| const stepHeader = `### ${stepName}`; |
| const stepSection = `${stepHeader}\n\n${normalizedSummary}`; |
|
|
| if (feature.summary) { |
| |
| |
| const separator = '\n\n---\n\n'; |
| const sections = feature.summary.split(separator); |
| let replaced = false; |
| const updatedSections = sections.map((section) => { |
| if (section.startsWith(`${stepHeader}\n\n`)) { |
| replaced = true; |
| return stepSection; |
| } |
| return section; |
| }); |
|
|
| if (replaced) { |
| feature.summary = updatedSections.join(separator); |
| logger.info( |
| `[saveFeatureSummary] Updated existing pipeline step summary for feature ${featureId}: step="${stepName}"` |
| ); |
| } else { |
| |
| feature.summary = `${feature.summary}${separator}${stepSection}`; |
| logger.info( |
| `[saveFeatureSummary] Appended new pipeline step summary for feature ${featureId}: step="${stepName}"` |
| ); |
| } |
| } else { |
| feature.summary = stepSection; |
| logger.info( |
| `[saveFeatureSummary] Initialized pipeline summary for feature ${featureId}: step="${stepName}"` |
| ); |
| } |
| } else { |
| feature.summary = normalizedSummary; |
| } |
|
|
| feature.updatedAt = new Date().toISOString(); |
|
|
| |
| await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); |
|
|
| |
| this.emitAutoModeEvent('auto_mode_summary', { |
| featureId, |
| projectPath, |
| summary: feature.summary, |
| }); |
| } catch (error) { |
| logger.error(`Failed to save summary for ${featureId}:`, error); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| private async getPipelineStepName(projectPath: string, status: string): Promise<string> { |
| try { |
| const stepId = pipelineService.getStepIdFromStatus(status as FeatureStatusWithPipeline); |
| if (stepId) { |
| const step = await pipelineService.getStep(projectPath, stepId); |
| if (step) return step.name; |
| } |
| } catch (error) { |
| logger.debug( |
| `[getPipelineStepName] Failed to look up step name for status "${status}", using fallback:`, |
| error |
| ); |
| } |
| |
| |
| const suffix = status.replace('pipeline_', ''); |
| return suffix |
| .split('_') |
| .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) |
| .join(' '); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async updateTaskStatus( |
| projectPath: string, |
| featureId: string, |
| taskId: string, |
| status: ParsedTask['status'], |
| summary?: string |
| ): Promise<void> { |
| const featureDir = getFeatureDir(projectPath, featureId); |
| const featurePath = path.join(featureDir, 'feature.json'); |
|
|
| try { |
| const result = await readJsonWithRecovery<Feature | null>(featurePath, null, { |
| maxBackups: DEFAULT_BACKUP_COUNT, |
| autoRestore: true, |
| }); |
|
|
| logRecoveryWarning(result, `Feature ${featureId}`, logger); |
|
|
| const feature = result.data; |
| if (!feature || !feature.planSpec?.tasks) { |
| logger.warn(`Feature ${featureId} not found or has no tasks`); |
| return; |
| } |
|
|
| |
| const task = feature.planSpec.tasks.find((t) => t.id === taskId); |
| if (task) { |
| task.status = status; |
| if (summary) { |
| task.summary = summary; |
| } |
| feature.updatedAt = new Date().toISOString(); |
|
|
| |
| await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); |
|
|
| |
| this.emitAutoModeEvent('auto_mode_task_status', { |
| featureId, |
| projectPath, |
| taskId, |
| status, |
| summary, |
| tasks: feature.planSpec.tasks, |
| }); |
| } else { |
| const availableIds = feature.planSpec.tasks.map((t) => t.id).join(', '); |
| logger.warn( |
| `[updateTaskStatus] Task ${taskId} not found in feature ${featureId} (${projectPath}). Available task IDs: [${availableIds}]` |
| ); |
| } |
| } catch (error) { |
| logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| private getFeatureDisplayName(feature: Feature, featureId: string): string { |
| |
| return feature.title && feature.title.trim() ? feature.title : featureId; |
| } |
|
|
| |
| |
| |
| |
| private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise<void> { |
| if (!payload.type) return; |
|
|
| |
| if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') { |
| return; |
| } |
|
|
| |
| if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) { |
| return; |
| } |
|
|
| |
| const projectPath = payload.projectPath; |
| if (!projectPath) return; |
|
|
| try { |
| const notificationService = getNotificationService(); |
|
|
| |
| |
| const isFeatureError = payload.type === 'auto_mode_feature_complete'; |
| const notificationType = isFeatureError |
| ? NOTIFICATION_TYPE_FEATURE_ERROR |
| : NOTIFICATION_TYPE_AUTO_MODE_ERROR; |
| const notificationTitle = isFeatureError |
| ? NOTIFICATION_TITLE_FEATURE_ERROR |
| : NOTIFICATION_TITLE_AUTO_MODE_ERROR; |
|
|
| |
| let errorMessage = payload.message || 'An error occurred'; |
| if (payload.error) { |
| errorMessage = payload.error; |
| } |
|
|
| |
| let title = notificationTitle; |
| if (payload.featureId) { |
| const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId); |
| if (displayName) { |
| title = displayName; |
| errorMessage = `${notificationTitle}: ${errorMessage}`; |
| } |
| } |
|
|
| await notificationService.createNotification({ |
| type: notificationType, |
| title, |
| message: errorMessage, |
| featureId: payload.featureId, |
| projectPath, |
| }); |
| } catch (notificationError) { |
| logger.warn(`Failed to create error notification:`, notificationError); |
| } |
| } |
|
|
| |
| |
| |
| private async getFeatureDisplayNameById( |
| projectPath: string, |
| featureId: string |
| ): Promise<string | null> { |
| const feature = await this.loadFeature(projectPath, featureId); |
| if (!feature) return null; |
| return this.getFeatureDisplayName(feature, featureId); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void { |
| if (!feature.planSpec?.tasks) { |
| return; |
| } |
|
|
| let tasksFinalized = 0; |
| let tasksPending = 0; |
|
|
| for (const task of feature.planSpec.tasks) { |
| if (task.status === 'in_progress') { |
| task.status = 'completed'; |
| tasksFinalized++; |
| } else if (task.status === 'pending') { |
| tasksPending++; |
| } |
| } |
|
|
| |
| feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( |
| (t) => t.status === 'completed' |
| ).length; |
| feature.planSpec.currentTaskId = undefined; |
|
|
| if (tasksFinalized > 0) { |
| logger.info( |
| `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}` |
| ); |
| } |
|
|
| if (tasksPending > 0) { |
| logger.warn( |
| `[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| private emitAutoModeEvent(eventType: AutoModeEventType, data: Record<string, unknown>): void { |
| |
| this.events.emit('auto-mode:event', { |
| type: eventType, |
| ...data, |
| }); |
| } |
| } |
|
|