diff --git a/apps/server/src/lib/xml-extractor.ts b/apps/server/src/lib/xml-extractor.ts index 49dbc368..72963b82 100644 --- a/apps/server/src/lib/xml-extractor.ts +++ b/apps/server/src/lib/xml-extractor.ts @@ -463,3 +463,149 @@ export function fromSpecOutputFeatures( : {}), })); } + +/** + * Represents a roadmap phase extracted from XML + */ +export interface RoadmapPhase { + name: string; + status: string; + description?: string; +} + +/** + * Extract the technology stack from app_spec.txt XML content + * + * @param specContent - The full XML content + * @param options - Optional extraction options + * @returns Array of technology names + */ +export function extractTechnologyStack( + specContent: string, + options: ExtractXmlOptions = {} +): string[] { + const log = options.logger || logger; + + const techSection = extractXmlSection(specContent, 'technology_stack', options); + if (!techSection) { + log.debug('No technology_stack section found'); + return []; + } + + const technologies = extractXmlElements(techSection, 'technology', options); + log.debug(`Extracted ${technologies.length} technologies`); + return technologies; +} + +/** + * Update the technology_stack section in XML content + * + * @param specContent - The full XML content + * @param technologies - The new technology list + * @param options - Optional extraction options + * @returns Updated XML content + */ +export function updateTechnologyStack( + specContent: string, + technologies: string[], + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + const indent = ' '; + const i2 = indent.repeat(2); + + // Generate new section content + const techXml = technologies + .map((t) => `${i2}${escapeXml(t)}`) + .join('\n'); + const newSection = `\n${techXml}\n${indent}`; + + // Check if section exists + const sectionRegex = /[\s\S]*?<\/technology_stack>/; + + if (sectionRegex.test(specContent)) { + log.debug('Replacing existing technology_stack section'); + return specContent.replace(sectionRegex, newSection); + } + + log.debug('No technology_stack section found to update'); + return specContent; +} + +/** + * Extract roadmap phases from app_spec.txt XML content + * + * @param specContent - The full XML content + * @param options - Optional extraction options + * @returns Array of roadmap phases + */ +export function extractRoadmapPhases( + specContent: string, + options: ExtractXmlOptions = {} +): RoadmapPhase[] { + const log = options.logger || logger; + const phases: RoadmapPhase[] = []; + + const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options); + if (!roadmapSection) { + log.debug('No implementation_roadmap section found'); + return phases; + } + + // Extract individual phase blocks + const phaseRegex = /([\s\S]*?)<\/phase>/g; + const phaseMatches = roadmapSection.matchAll(phaseRegex); + + for (const phaseMatch of phaseMatches) { + const phaseContent = phaseMatch[1]; + + const nameMatch = phaseContent.match(/([\s\S]*?)<\/name>/); + const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : ''; + + const statusMatch = phaseContent.match(/([\s\S]*?)<\/status>/); + const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending'; + + const descMatch = phaseContent.match(/([\s\S]*?)<\/description>/); + const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined; + + if (name) { + phases.push({ name, status, description }); + } + } + + log.debug(`Extracted ${phases.length} roadmap phases`); + return phases; +} + +/** + * Update a roadmap phase status in XML content + * + * @param specContent - The full XML content + * @param phaseName - The name of the phase to update + * @param newStatus - The new status value + * @param options - Optional extraction options + * @returns Updated XML content + */ +export function updateRoadmapPhaseStatus( + specContent: string, + phaseName: string, + newStatus: string, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Find the phase and update its status + // Match the phase block containing the specific name + const phaseRegex = new RegExp( + `(\\s*\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*)[\\s\\S]*?(<\\/status>)`, + 'i' + ); + + if (phaseRegex.test(specContent)) { + log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`); + return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`); + } + + log.debug(`Phase "${phaseName}" not found`); + return specContent; +} diff --git a/apps/server/src/routes/app-spec/common.ts b/apps/server/src/routes/app-spec/common.ts index 7ef1aabe..1a48fc6a 100644 --- a/apps/server/src/routes/app-spec/common.ts +++ b/apps/server/src/routes/app-spec/common.ts @@ -6,8 +6,17 @@ import { createLogger } from '@automaker/utils'; const logger = createLogger('SpecRegeneration'); +// Types for running generation +export type GenerationType = 'spec_regeneration' | 'feature_generation' | 'sync'; + +interface RunningGeneration { + isRunning: boolean; + type: GenerationType; + startedAt: string; +} + // Shared state for tracking generation status - scoped by project path -const runningProjects = new Map(); +const runningProjects = new Map(); const abortControllers = new Map(); /** @@ -17,16 +26,21 @@ export function getSpecRegenerationStatus(projectPath?: string): { isRunning: boolean; currentAbortController: AbortController | null; projectPath?: string; + type?: GenerationType; + startedAt?: string; } { if (projectPath) { + const generation = runningProjects.get(projectPath); return { - isRunning: runningProjects.get(projectPath) || false, + isRunning: generation?.isRunning || false, currentAbortController: abortControllers.get(projectPath) || null, projectPath, + type: generation?.type, + startedAt: generation?.startedAt, }; } // Fallback: check if any project is running (for backward compatibility) - const isAnyRunning = Array.from(runningProjects.values()).some((running) => running); + const isAnyRunning = Array.from(runningProjects.values()).some((g) => g.isRunning); return { isRunning: isAnyRunning, currentAbortController: null }; } @@ -46,10 +60,15 @@ export function getRunningProjectPath(): string | null { export function setRunningState( projectPath: string, running: boolean, - controller: AbortController | null = null + controller: AbortController | null = null, + type: GenerationType = 'spec_regeneration' ): void { if (running) { - runningProjects.set(projectPath, true); + runningProjects.set(projectPath, { + isRunning: true, + type, + startedAt: new Date().toISOString(), + }); if (controller) { abortControllers.set(projectPath, controller); } @@ -59,6 +78,33 @@ export function setRunningState( } } +/** + * Get all running spec/feature generations for the running agents view + */ +export function getAllRunningGenerations(): Array<{ + projectPath: string; + type: GenerationType; + startedAt: string; +}> { + const results: Array<{ + projectPath: string; + type: GenerationType; + startedAt: string; + }> = []; + + for (const [projectPath, generation] of runningProjects.entries()) { + if (generation.isRunning) { + results.push({ + projectPath, + type: generation.type, + startedAt: generation.startedAt, + }); + } + } + + return results; +} + /** * Helper to log authentication status */ diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index c149d5f0..24c3f398 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -15,6 +15,7 @@ import { parseAndCreateFeatures } from './parse-and-create-features.js'; import { getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; +import { FeatureLoader } from '../../services/feature-loader.js'; const logger = createLogger('SpecRegeneration'); @@ -56,13 +57,45 @@ export async function generateFeaturesFromSpec( // Get customized prompts from settings const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]'); + // Load existing features to prevent duplicates + const featureLoader = new FeatureLoader(); + const existingFeatures = await featureLoader.getAll(projectPath); + + logger.info(`Found ${existingFeatures.length} existing features to exclude from generation`); + + // Build existing features context for the prompt + let existingFeaturesContext = ''; + if (existingFeatures.length > 0) { + const featuresList = existingFeatures + .map( + (f) => + `- "${f.title}" (ID: ${f.id}): ${f.description?.substring(0, 100) || 'No description'}` + ) + .join('\n'); + existingFeaturesContext = ` + +## EXISTING FEATURES (DO NOT REGENERATE THESE) + +The following ${existingFeatures.length} features already exist in the project. You MUST NOT generate features that duplicate or overlap with these: + +${featuresList} + +CRITICAL INSTRUCTIONS: +- DO NOT generate any features with the same or similar titles as the existing features listed above +- DO NOT generate features that cover the same functionality as existing features +- ONLY generate NEW features that are not yet in the system +- If a feature from the roadmap already exists, skip it entirely +- Generate unique feature IDs that do not conflict with existing IDs: ${existingFeatures.map((f) => f.id).join(', ')} +`; + } + const prompt = `Based on this project specification: ${spec} - +${existingFeaturesContext} ${prompts.appSpec.generateFeaturesFromSpecPrompt} -Generate ${featureCount} features that build on each other logically.`; +Generate ${featureCount} NEW features that build on each other logically. Remember: ONLY generate features that DO NOT already exist.`; logger.info('========== PROMPT BEING SENT =========='); logger.info(`Prompt length: ${prompt.length} chars`); diff --git a/apps/server/src/routes/app-spec/index.ts b/apps/server/src/routes/app-spec/index.ts index 342aecd7..79b8e44d 100644 --- a/apps/server/src/routes/app-spec/index.ts +++ b/apps/server/src/routes/app-spec/index.ts @@ -7,6 +7,7 @@ import type { EventEmitter } from '../../lib/events.js'; import { createCreateHandler } from './routes/create.js'; import { createGenerateHandler } from './routes/generate.js'; import { createGenerateFeaturesHandler } from './routes/generate-features.js'; +import { createSyncHandler } from './routes/sync.js'; import { createStopHandler } from './routes/stop.js'; import { createStatusHandler } from './routes/status.js'; import type { SettingsService } from '../../services/settings-service.js'; @@ -20,6 +21,7 @@ export function createSpecRegenerationRoutes( router.post('/create', createCreateHandler(events)); router.post('/generate', createGenerateHandler(events, settingsService)); router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService)); + router.post('/sync', createSyncHandler(events, settingsService)); router.post('/stop', createStopHandler()); router.get('/status', createStatusHandler()); diff --git a/apps/server/src/routes/app-spec/routes/generate-features.ts b/apps/server/src/routes/app-spec/routes/generate-features.ts index dc627964..670652ea 100644 --- a/apps/server/src/routes/app-spec/routes/generate-features.ts +++ b/apps/server/src/routes/app-spec/routes/generate-features.ts @@ -50,7 +50,7 @@ export function createGenerateFeaturesHandler( logAuthStatus('Before starting feature generation'); const abortController = new AbortController(); - setRunningState(projectPath, true, abortController); + setRunningState(projectPath, true, abortController, 'feature_generation'); logger.info('Starting background feature generation task...'); generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) diff --git a/apps/server/src/routes/app-spec/routes/sync.ts b/apps/server/src/routes/app-spec/routes/sync.ts new file mode 100644 index 00000000..c6c34e68 --- /dev/null +++ b/apps/server/src/routes/app-spec/routes/sync.ts @@ -0,0 +1,76 @@ +/** + * POST /sync endpoint - Sync spec with codebase and features + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { + getSpecRegenerationStatus, + setRunningState, + logAuthStatus, + logError, + getErrorMessage, +} from '../common.js'; +import { syncSpec } from '../sync-spec.js'; +import type { SettingsService } from '../../../services/settings-service.js'; + +const logger = createLogger('SpecSync'); + +export function createSyncHandler(events: EventEmitter, settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + logger.info('========== /sync endpoint called =========='); + logger.debug('Request body:', JSON.stringify(req.body, null, 2)); + + try { + const { projectPath } = req.body as { + projectPath: string; + }; + + logger.debug('projectPath:', projectPath); + + if (!projectPath) { + logger.error('Missing projectPath parameter'); + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + const { isRunning } = getSpecRegenerationStatus(projectPath); + if (isRunning) { + logger.warn('Generation/sync already running for project:', projectPath); + res.json({ success: false, error: 'Operation already running for this project' }); + return; + } + + logAuthStatus('Before starting spec sync'); + + const abortController = new AbortController(); + setRunningState(projectPath, true, abortController, 'sync'); + logger.info('Starting background spec sync task...'); + + syncSpec(projectPath, events, abortController, settingsService) + .then((result) => { + logger.info('Spec sync completed successfully'); + logger.info('Result:', JSON.stringify(result, null, 2)); + }) + .catch((error) => { + logError(error, 'Spec sync failed with error'); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: getErrorMessage(error), + projectPath, + }); + }) + .finally(() => { + logger.info('Spec sync task finished (success or error)'); + setRunningState(projectPath, false, null); + }); + + logger.info('Returning success response (sync running in background)'); + res.json({ success: true }); + } catch (error) { + logError(error, 'Sync route handler failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts new file mode 100644 index 00000000..98352855 --- /dev/null +++ b/apps/server/src/routes/app-spec/sync-spec.ts @@ -0,0 +1,307 @@ +/** + * Sync spec with current codebase and feature state + * + * Updates the spec file based on: + * - Completed Automaker features + * - Code analysis for tech stack and implementations + * - Roadmap phase status updates + */ + +import * as secureFs from '../../lib/secure-fs.js'; +import type { EventEmitter } from '../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { streamingQuery } from '../../providers/simple-query-service.js'; +import { getAppSpecPath } from '@automaker/platform'; +import type { SettingsService } from '../../services/settings-service.js'; +import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import { FeatureLoader } from '../../services/feature-loader.js'; +import { + extractImplementedFeatures, + extractTechnologyStack, + extractRoadmapPhases, + updateImplementedFeaturesSection, + updateTechnologyStack, + updateRoadmapPhaseStatus, + type ImplementedFeature, + type RoadmapPhase, +} from '../../lib/xml-extractor.js'; +import { getNotificationService } from '../../services/notification-service.js'; + +const logger = createLogger('SpecSync'); + +/** + * Result of a sync operation + */ +export interface SyncResult { + techStackUpdates: { + added: string[]; + removed: string[]; + }; + implementedFeaturesUpdates: { + addedFromFeatures: string[]; + removed: string[]; + }; + roadmapUpdates: Array<{ phaseName: string; newStatus: string }>; + summary: string; +} + +/** + * Sync the spec with current codebase and feature state + */ +export async function syncSpec( + projectPath: string, + events: EventEmitter, + abortController: AbortController, + settingsService?: SettingsService +): Promise { + logger.info('========== syncSpec() started =========='); + logger.info('projectPath:', projectPath); + + const result: SyncResult = { + techStackUpdates: { added: [], removed: [] }, + implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] }, + roadmapUpdates: [], + summary: '', + }; + + // Read existing spec + const specPath = getAppSpecPath(projectPath); + let specContent: string; + + try { + specContent = (await secureFs.readFile(specPath, 'utf-8')) as string; + logger.info(`Spec loaded successfully (${specContent.length} chars)`); + } catch (readError) { + logger.error('Failed to read spec file:', readError); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: 'No project spec found. Create or regenerate spec first.', + projectPath, + }); + throw new Error('No project spec found'); + } + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: '[Phase: sync] Starting spec sync...\n', + projectPath, + }); + + // Extract current state from spec + const currentImplementedFeatures = extractImplementedFeatures(specContent); + const currentTechStack = extractTechnologyStack(specContent); + const currentRoadmapPhases = extractRoadmapPhases(specContent); + + logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`); + logger.info(`Current spec has ${currentTechStack.length} technologies`); + logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`); + + // Load completed Automaker features + const featureLoader = new FeatureLoader(); + const allFeatures = await featureLoader.getAll(projectPath); + const completedFeatures = allFeatures.filter( + (f) => f.status === 'completed' || f.status === 'verified' + ); + + logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`); + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: `Found ${completedFeatures.length} completed features to sync...\n`, + projectPath, + }); + + // Build new implemented features list from completed Automaker features + const newImplementedFeatures: ImplementedFeature[] = []; + const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase())); + + for (const feature of completedFeatures) { + const name = feature.title || `Feature: ${feature.id}`; + if (!existingNames.has(name.toLowerCase())) { + newImplementedFeatures.push({ + name, + description: feature.description || '', + }); + result.implementedFeaturesUpdates.addedFromFeatures.push(name); + } + } + + // Merge: keep existing + add new from completed features + const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures]; + + // Update spec with merged features + if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) { + specContent = updateImplementedFeaturesSection(specContent, mergedFeatures); + logger.info( + `Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec` + ); + } + + // Analyze codebase for tech stack updates using AI + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: 'Analyzing codebase for technology updates...\n', + projectPath, + }); + + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[SpecSync]' + ); + + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel; + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + + // Use AI to analyze tech stack + const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack. + +Current known technologies: ${currentTechStack.join(', ')} + +Look at package.json, config files, and source code to identify: +- Frameworks (React, Vue, Express, etc.) +- Languages (TypeScript, JavaScript, Python, etc.) +- Build tools (Vite, Webpack, etc.) +- Databases (PostgreSQL, MongoDB, etc.) +- Key libraries and tools + +Return ONLY this JSON format, no other text: +{ + "technologies": ["Technology 1", "Technology 2", ...] +}`; + + try { + const techResult = await streamingQuery({ + prompt: techAnalysisPrompt, + model, + cwd: projectPath, + maxTurns: 10, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + onText: (text) => { + logger.debug(`Tech analysis text: ${text.substring(0, 100)}`); + }, + }); + + // Parse tech stack from response + const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + if (Array.isArray(parsed.technologies)) { + const newTechStack = parsed.technologies as string[]; + + // Calculate differences + const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase())); + const newSet = new Set(newTechStack.map((t) => t.toLowerCase())); + + for (const tech of newTechStack) { + if (!currentSet.has(tech.toLowerCase())) { + result.techStackUpdates.added.push(tech); + } + } + + for (const tech of currentTechStack) { + if (!newSet.has(tech.toLowerCase())) { + result.techStackUpdates.removed.push(tech); + } + } + + // Update spec with new tech stack if there are changes + if ( + result.techStackUpdates.added.length > 0 || + result.techStackUpdates.removed.length > 0 + ) { + specContent = updateTechnologyStack(specContent, newTechStack); + logger.info( + `Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}` + ); + } + } + } + } catch (error) { + logger.warn('Failed to analyze tech stack:', error); + // Continue with other sync operations + } + + // Update roadmap phase statuses based on completed features + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: 'Checking roadmap phase statuses...\n', + projectPath, + }); + + // For each phase, check if all its features are completed + // This is a heuristic - we check if the phase name appears in any feature titles/descriptions + for (const phase of currentRoadmapPhases) { + if (phase.status === 'completed') continue; // Already completed + + // Check if this phase should be marked as completed + // A phase is considered complete if we have completed features that mention it + const phaseNameLower = phase.name.toLowerCase(); + const relatedCompletedFeatures = completedFeatures.filter( + (f) => + f.title?.toLowerCase().includes(phaseNameLower) || + f.description?.toLowerCase().includes(phaseNameLower) || + f.category?.toLowerCase().includes(phaseNameLower) + ); + + // If we have related completed features and the phase is still pending/in_progress, + // update it to in_progress or completed based on feature count + if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') { + const newStatus = 'in_progress'; + specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus); + result.roadmapUpdates.push({ phaseName: phase.name, newStatus }); + logger.info(`Updated phase "${phase.name}" to ${newStatus}`); + } + } + + // Save updated spec + await secureFs.writeFile(specPath, specContent, 'utf-8'); + logger.info('Spec saved successfully'); + + // Build summary + const summaryParts: string[] = []; + if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) { + summaryParts.push( + `Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features` + ); + } + if (result.techStackUpdates.added.length > 0) { + summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`); + } + if (result.techStackUpdates.removed.length > 0) { + summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`); + } + if (result.roadmapUpdates.length > 0) { + summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`); + } + + result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date'; + + // Create notification + const notificationService = getNotificationService(); + await notificationService.createNotification({ + type: 'spec_regeneration_complete', + title: 'Spec Sync Complete', + message: result.summary, + projectPath, + }); + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_complete', + message: `Spec sync complete! ${result.summary}`, + projectPath, + }); + + logger.info('========== syncSpec() completed =========='); + logger.info('Summary:', result.summary); + + return result; +} diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts index 955ac93d..1eeb4ae6 100644 --- a/apps/server/src/routes/running-agents/routes/index.ts +++ b/apps/server/src/routes/running-agents/routes/index.ts @@ -5,6 +5,7 @@ import type { Request, Response } from 'express'; import type { AutoModeService } from '../../../services/auto-mode-service.js'; import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js'; +import { getAllRunningGenerations } from '../../app-spec/common.js'; import path from 'path'; import { getErrorMessage, logError } from '../common.js'; @@ -26,6 +27,36 @@ export function createIndexHandler(autoModeService: AutoModeService) { }); } + // Add spec/feature generation tasks + const specGenerations = getAllRunningGenerations(); + for (const generation of specGenerations) { + let title: string; + let description: string; + + switch (generation.type) { + case 'feature_generation': + title = 'Generating features from spec'; + description = 'Creating features from the project specification'; + break; + case 'sync': + title = 'Syncing spec with code'; + description = 'Updating spec from codebase and completed features'; + break; + default: + title = 'Regenerating spec'; + description = 'Analyzing project and generating specification'; + } + + runningAgents.push({ + featureId: `spec-generation:${generation.projectPath}`, + projectPath: generation.projectPath, + projectName: path.basename(generation.projectPath), + isAutoMode: false, + title, + description, + }); + } + res.json({ success: true, runningAgents, diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts index e6e79cd8..964e6f3f 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { getElectronAPI } from '@/lib/electron'; @@ -6,6 +6,7 @@ const logger = createLogger('RunningAgents'); export function useRunningAgents() { const [runningAgentsCount, setRunningAgentsCount] = useState(0); + const fetchTimeoutRef = useRef(null); // Fetch running agents count function - used for initial load and event-driven updates const fetchRunningAgentsCount = useCallback(async () => { @@ -32,6 +33,16 @@ export function useRunningAgents() { } }, []); + // Debounced fetch to avoid excessive API calls from frequent events + const debouncedFetchRunningAgentsCount = useCallback(() => { + if (fetchTimeoutRef.current) { + clearTimeout(fetchTimeoutRef.current); + } + fetchTimeoutRef.current = setTimeout(() => { + fetchRunningAgentsCount(); + }, 300); + }, [fetchRunningAgentsCount]); + // Subscribe to auto-mode events to update running agents count in real-time useEffect(() => { const api = getElectronAPI(); @@ -80,6 +91,41 @@ export function useRunningAgents() { }; }, [fetchRunningAgentsCount]); + // Subscribe to spec regeneration events to update running agents count + useEffect(() => { + const api = getElectronAPI(); + if (!api.specRegeneration) return; + + fetchRunningAgentsCount(); + + const unsubscribe = api.specRegeneration.onEvent((event) => { + logger.debug('Spec regeneration event for running agents hook', { + type: event.type, + }); + // When spec regeneration completes or errors, refresh immediately + if (event.type === 'spec_regeneration_complete' || event.type === 'spec_regeneration_error') { + fetchRunningAgentsCount(); + } + // For progress events, use debounced fetch to avoid excessive calls + else if (event.type === 'spec_regeneration_progress') { + debouncedFetchRunningAgentsCount(); + } + }); + + return () => { + unsubscribe(); + }; + }, [fetchRunningAgentsCount, debouncedFetchRunningAgentsCount]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (fetchTimeoutRef.current) { + clearTimeout(fetchTimeoutRef.current); + } + }; + }, []); + return { runningAgentsCount, }; diff --git a/apps/ui/src/components/views/spec-view.tsx b/apps/ui/src/components/views/spec-view.tsx index 67725a64..616dc4dd 100644 --- a/apps/ui/src/components/views/spec-view.tsx +++ b/apps/ui/src/components/views/spec-view.tsx @@ -56,6 +56,9 @@ export function SpecView() { // Feature generation isGeneratingFeatures, + // Sync + isSyncing, + // Status currentPhase, errorMessage, @@ -63,6 +66,8 @@ export function SpecView() { // Handlers handleCreateSpec, handleRegenerate, + handleGenerateFeatures, + handleSync, } = useSpecGeneration({ loadSpec }); // Reset hasChanges when spec is reloaded @@ -86,10 +91,9 @@ export function SpecView() { ); } - // Empty state - no spec exists or generation is running - // When generation is running, we skip loading the spec to avoid 500 errors, - // so we show the empty state with generation indicator - if (!specExists || isGenerationRunning) { + // Empty state - only show when spec doesn't exist AND no generation is running + // If generation is running but no spec exists, show the generating UI + if (!specExists) { // If generation is running (from loading hook check), ensure we show the generating UI const showAsGenerating = isCreating || isGenerationRunning; @@ -127,14 +131,17 @@ export function SpecView() {
setShowRegenerateDialog(true)} + onGenerateFeaturesClick={handleGenerateFeatures} + onSyncClick={handleSync} onSaveClick={saveSpec} showActionsPanel={showActionsPanel} onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)} diff --git a/apps/ui/src/components/views/spec-view/components/spec-header.tsx b/apps/ui/src/components/views/spec-view/components/spec-header.tsx index 37132701..b38a6579 100644 --- a/apps/ui/src/components/views/spec-view/components/spec-header.tsx +++ b/apps/ui/src/components/views/spec-view/components/spec-header.tsx @@ -3,7 +3,7 @@ import { HeaderActionsPanel, HeaderActionsPanelTrigger, } from '@/components/ui/header-actions-panel'; -import { Save, Sparkles, Loader2, FileText, AlertCircle } from 'lucide-react'; +import { Save, Sparkles, Loader2, FileText, AlertCircle, ListPlus, RefreshCcw } from 'lucide-react'; import { PHASE_LABELS } from '../constants'; interface SpecHeaderProps { @@ -11,11 +11,14 @@ interface SpecHeaderProps { isRegenerating: boolean; isCreating: boolean; isGeneratingFeatures: boolean; + isSyncing: boolean; isSaving: boolean; hasChanges: boolean; currentPhase: string; errorMessage: string; onRegenerateClick: () => void; + onGenerateFeaturesClick: () => void; + onSyncClick: () => void; onSaveClick: () => void; showActionsPanel: boolean; onToggleActionsPanel: () => void; @@ -26,16 +29,19 @@ export function SpecHeader({ isRegenerating, isCreating, isGeneratingFeatures, + isSyncing, isSaving, hasChanges, currentPhase, errorMessage, onRegenerateClick, + onGenerateFeaturesClick, + onSyncClick, onSaveClick, showActionsPanel, onToggleActionsPanel, }: SpecHeaderProps) { - const isProcessing = isRegenerating || isCreating || isGeneratingFeatures; + const isProcessing = isRegenerating || isCreating || isGeneratingFeatures || isSyncing; const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase; return ( @@ -58,11 +64,13 @@ export function SpecHeader({
- {isGeneratingFeatures - ? 'Generating Features' - : isCreating - ? 'Generating Specification' - : 'Regenerating Specification'} + {isSyncing + ? 'Syncing Specification' + : isGeneratingFeatures + ? 'Generating Features' + : isCreating + ? 'Generating Specification' + : 'Regenerating Specification'} {currentPhase && ( @@ -99,32 +107,42 @@ export function SpecHeader({ Error
)} - {/* Desktop: show actions inline */} -
- + - -
+ Regenerate + + + + + )} {/* Tablet/Mobile: show trigger for actions panel */} @@ -142,11 +160,13 @@ export function SpecHeader({
- {isGeneratingFeatures - ? 'Generating Features' - : isCreating - ? 'Generating Specification' - : 'Regenerating Specification'} + {isSyncing + ? 'Syncing Specification' + : isGeneratingFeatures + ? 'Generating Features' + : isCreating + ? 'Generating Specification' + : 'Regenerating Specification'} {currentPhase && {phaseLabel}}
@@ -161,29 +181,47 @@ export function SpecHeader({ )} - - + {/* Hide action buttons when processing - status card shows progress */} + {!isProcessing && ( + <> + + + + + + )} ); diff --git a/apps/ui/src/components/views/spec-view/constants.ts b/apps/ui/src/components/views/spec-view/constants.ts index 5b4a5a4a..f6c32a80 100644 --- a/apps/ui/src/components/views/spec-view/constants.ts +++ b/apps/ui/src/components/views/spec-view/constants.ts @@ -24,6 +24,7 @@ export const PHASE_LABELS: Record = { analysis: 'Analyzing project structure...', spec_complete: 'Spec created! Generating features...', feature_generation: 'Creating features from roadmap...', + working: 'Working...', complete: 'Complete!', error: 'Error occurred', }; diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts index 69054125..30a8150f 100644 --- a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts +++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts @@ -39,6 +39,9 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { // Generate features only state const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false); + // Sync state + const [isSyncing, setIsSyncing] = useState(false); + // Logs state (kept for internal tracking) const [logs, setLogs] = useState(''); const logsRef = useRef(''); @@ -55,6 +58,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setIsCreating(false); setIsRegenerating(false); setIsGeneratingFeatures(false); + setIsSyncing(false); setCurrentPhase(''); setErrorMessage(''); setLogs(''); @@ -135,7 +139,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { if ( !document.hidden && currentProject && - (isCreating || isRegenerating || isGeneratingFeatures) + (isCreating || isRegenerating || isGeneratingFeatures || isSyncing) ) { try { const api = getElectronAPI(); @@ -151,6 +155,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setIsCreating(false); setIsRegenerating(false); setIsGeneratingFeatures(false); + setIsSyncing(false); setCurrentPhase(''); stateRestoredRef.current = false; loadSpec(); @@ -167,11 +172,12 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, loadSpec]); + }, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, isSyncing, loadSpec]); // Periodic status check useEffect(() => { - if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures)) return; + if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures && !isSyncing)) + return; const intervalId = setInterval(async () => { try { @@ -187,6 +193,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setIsCreating(false); setIsRegenerating(false); setIsGeneratingFeatures(false); + setIsSyncing(false); setCurrentPhase(''); stateRestoredRef.current = false; loadSpec(); @@ -205,7 +212,15 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { return () => { clearInterval(intervalId); }; - }, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, currentPhase, loadSpec]); + }, [ + currentProject, + isCreating, + isRegenerating, + isGeneratingFeatures, + isSyncing, + currentPhase, + loadSpec, + ]); // Subscribe to spec regeneration events useEffect(() => { @@ -317,7 +332,8 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { event.message === 'All tasks completed!' || event.message === 'All tasks completed' || event.message === 'Spec regeneration complete!' || - event.message === 'Initial spec creation complete!'; + event.message === 'Initial spec creation complete!' || + event.message?.includes('Spec sync complete'); const hasCompletePhase = logsRef.current.includes('[Phase: complete]'); @@ -337,6 +353,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setIsRegenerating(false); setIsCreating(false); setIsGeneratingFeatures(false); + setIsSyncing(false); setCurrentPhase(''); setShowRegenerateDialog(false); setShowCreateDialog(false); @@ -349,18 +366,23 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { loadSpec(); }, SPEC_FILE_WRITE_DELAY); + const isSyncComplete = event.message?.includes('sync'); const isRegeneration = event.message?.includes('regeneration'); const isFeatureGeneration = event.message?.includes('Feature generation'); toast.success( - isFeatureGeneration - ? 'Feature Generation Complete' - : isRegeneration - ? 'Spec Regeneration Complete' - : 'Spec Creation Complete', + isSyncComplete + ? 'Spec Sync Complete' + : isFeatureGeneration + ? 'Feature Generation Complete' + : isRegeneration + ? 'Spec Regeneration Complete' + : 'Spec Creation Complete', { - description: isFeatureGeneration - ? 'Features have been created from the app specification.' - : 'Your app specification has been saved.', + description: isSyncComplete + ? 'Your spec has been updated with the latest changes.' + : isFeatureGeneration + ? 'Features have been created from the app specification.' + : 'Your app specification has been saved.', icon: createElement(CheckCircle2, { className: 'w-4 h-4' }), } ); @@ -378,6 +400,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { setIsRegenerating(false); setIsCreating(false); setIsGeneratingFeatures(false); + setIsSyncing(false); setCurrentPhase('error'); setErrorMessage(event.error); stateRestoredRef.current = false; @@ -544,6 +567,46 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { } }, [currentProject]); + const handleSync = useCallback(async () => { + if (!currentProject) return; + + setIsSyncing(true); + setCurrentPhase('sync'); + setErrorMessage(''); + logsRef.current = ''; + setLogs(''); + logger.debug('[useSpecGeneration] Starting spec sync'); + try { + const api = getElectronAPI(); + if (!api.specRegeneration) { + logger.error('[useSpecGeneration] Spec regeneration not available'); + setIsSyncing(false); + return; + } + const result = await api.specRegeneration.sync(currentProject.path); + + if (!result.success) { + const errorMsg = result.error || 'Unknown error'; + logger.error('[useSpecGeneration] Failed to start spec sync:', errorMsg); + setIsSyncing(false); + setCurrentPhase('error'); + setErrorMessage(errorMsg); + const errorLog = `[Error] Failed to start spec sync: ${errorMsg}\n`; + logsRef.current = errorLog; + setLogs(errorLog); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('[useSpecGeneration] Failed to sync spec:', errorMsg); + setIsSyncing(false); + setCurrentPhase('error'); + setErrorMessage(errorMsg); + const errorLog = `[Error] Failed to sync spec: ${errorMsg}\n`; + logsRef.current = errorLog; + setLogs(errorLog); + } + }, [currentProject]); + return { // Dialog state showCreateDialog, @@ -576,6 +639,9 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { // Feature generation state isGeneratingFeatures, + // Sync state + isSyncing, + // Status state currentPhase, errorMessage, @@ -584,6 +650,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) { // Handlers handleCreateSpec, handleRegenerate, + handleSync, handleGenerateFeatures, }; } diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts index 4343e300..9fc09b81 100644 --- a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts +++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts @@ -18,20 +18,21 @@ export function useSpecLoading() { try { const api = getElectronAPI(); - // Check if spec generation is running before trying to load - // This prevents showing "No App Specification Found" during generation + // Check if spec generation is running if (api.specRegeneration) { const status = await api.specRegeneration.status(currentProject.path); if (status.success && status.isRunning) { - logger.debug('Spec generation is running for this project, skipping load'); + logger.debug('Spec generation is running for this project'); setIsGenerationRunning(true); - setIsLoading(false); - return; + } else { + setIsGenerationRunning(false); } + } else { + setIsGenerationRunning(false); } - // Always reset when generation is not running (handles edge case where api.specRegeneration might not be available) - setIsGenerationRunning(false); + // Always try to load the spec file, even if generation is running + // This allows users to view their existing spec while generating features const result = await api.readFile(`${currentProject.path}/.automaker/app_spec.txt`); if (result.success && result.content) { diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index fd9f8588..97167e89 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -437,6 +437,10 @@ export interface SpecRegenerationAPI { success: boolean; error?: string; }>; + sync: (projectPath: string) => Promise<{ + success: boolean; + error?: string; + }>; stop: (projectPath?: string) => Promise<{ success: boolean; error?: string }>; status: (projectPath?: string) => Promise<{ success: boolean; @@ -2742,6 +2746,30 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI { return { success: true }; }, + sync: async (projectPath: string) => { + if (mockSpecRegenerationRunning) { + return { + success: false, + error: 'Spec sync is already running', + }; + } + + mockSpecRegenerationRunning = true; + console.log(`[Mock] Syncing spec for: ${projectPath}`); + + // Simulate async spec sync (similar to feature generation but simpler) + setTimeout(() => { + emitSpecRegenerationEvent({ + type: 'spec_regeneration_complete', + message: 'Spec synchronized successfully', + projectPath, + }); + mockSpecRegenerationRunning = false; + }, 1000); + + return { success: true }; + }, + stop: async (_projectPath?: string) => { mockSpecRegenerationRunning = false; mockSpecRegenerationPhase = ''; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1b13baae..547fee7f 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1882,6 +1882,7 @@ export class HttpApiClient implements ElectronAPI { projectPath, maxFeatures, }), + sync: (projectPath: string) => this.post('/api/spec-regeneration/sync', { projectPath }), stop: (projectPath?: string) => this.post('/api/spec-regeneration/stop', { projectPath }), status: (projectPath?: string) => this.get( diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 054f7d4b..42e4200d 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -367,6 +367,11 @@ export interface SpecRegenerationAPI { error?: string; }>; + sync: (projectPath: string) => Promise<{ + success: boolean; + error?: string; + }>; + stop: (projectPath?: string) => Promise<{ success: boolean; error?: string; diff --git a/apps/ui/vite.config.mts b/apps/ui/vite.config.mts index 71a70cda..0d18997e 100644 --- a/apps/ui/vite.config.mts +++ b/apps/ui/vite.config.mts @@ -67,6 +67,7 @@ export default defineConfig(({ command }) => { server: { host: process.env.HOST || '0.0.0.0', port: parseInt(process.env.TEST_PORT || '3007', 10), + allowedHosts: true, }, build: { outDir: 'dist', diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index a582313d..f9849813 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -685,6 +685,12 @@ Format as JSON: Generate features that build on each other logically. +CRITICAL RULES: +- If an "EXISTING FEATURES" section is provided above, you MUST NOT generate any features that duplicate or overlap with those existing features +- Check each feature you generate against the existing features list - if it already exists, DO NOT include it +- Only generate truly NEW features that add value beyond what already exists +- Generate unique IDs that don't conflict with existing feature IDs + IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`; /** diff --git a/start-automaker.sh b/start-automaker.sh index b0664716..cff17b87 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -1049,9 +1049,9 @@ fi case $MODE in web) export TEST_PORT="$WEB_PORT" - export VITE_SERVER_URL="http://localhost:$SERVER_PORT" + export VITE_SERVER_URL="http://$HOSTNAME:$SERVER_PORT" export PORT="$SERVER_PORT" - export CORS_ORIGIN="http://localhost:$WEB_PORT,http://127.0.0.1:$WEB_PORT" + export CORS_ORIGIN="http://$HOSTNAME:$WEB_PORT,http://127.0.0.1:$WEB_PORT" export VITE_APP_MODE="1" if [ "$PRODUCTION_MODE" = true ]; then @@ -1067,7 +1067,7 @@ case $MODE in max_retries=30 server_ready=false for ((i=0; i /dev/null 2>&1; then + if curl -s "http://$HOSTNAME:$SERVER_PORT/api/health" > /dev/null 2>&1; then server_ready=true break fi