This is page 2 of 2. Use http://codebase.md/jhawkins11/task-manager-mcp?lines=false&page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── frontend │ ├── .gitignore │ ├── .npmrc │ ├── components.json │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.cjs │ ├── README.md │ ├── src │ │ ├── app.d.ts │ │ ├── app.html │ │ ├── app.pcss │ │ ├── lib │ │ │ ├── components │ │ │ │ ├── ImportTasksModal.svelte │ │ │ │ ├── QuestionModal.svelte │ │ │ │ ├── TaskFormModal.svelte │ │ │ │ └── ui │ │ │ │ ├── badge │ │ │ │ │ ├── badge.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── button │ │ │ │ │ ├── button.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── card │ │ │ │ │ ├── card-content.svelte │ │ │ │ │ ├── card-description.svelte │ │ │ │ │ ├── card-footer.svelte │ │ │ │ │ ├── card-header.svelte │ │ │ │ │ ├── card-title.svelte │ │ │ │ │ ├── card.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── checkbox │ │ │ │ │ ├── checkbox.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── dialog │ │ │ │ │ ├── dialog-content.svelte │ │ │ │ │ ├── dialog-description.svelte │ │ │ │ │ ├── dialog-footer.svelte │ │ │ │ │ ├── dialog-header.svelte │ │ │ │ │ ├── dialog-overlay.svelte │ │ │ │ │ ├── dialog-portal.svelte │ │ │ │ │ ├── dialog-title.svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── input │ │ │ │ │ ├── index.ts │ │ │ │ │ └── input.svelte │ │ │ │ ├── label │ │ │ │ │ ├── index.ts │ │ │ │ │ └── label.svelte │ │ │ │ ├── progress │ │ │ │ │ ├── index.ts │ │ │ │ │ └── progress.svelte │ │ │ │ ├── select │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── select-content.svelte │ │ │ │ │ ├── select-group-heading.svelte │ │ │ │ │ ├── select-item.svelte │ │ │ │ │ ├── select-scroll-down-button.svelte │ │ │ │ │ ├── select-scroll-up-button.svelte │ │ │ │ │ ├── select-separator.svelte │ │ │ │ │ └── select-trigger.svelte │ │ │ │ ├── separator │ │ │ │ │ ├── index.ts │ │ │ │ │ └── separator.svelte │ │ │ │ └── textarea │ │ │ │ ├── index.ts │ │ │ │ └── textarea.svelte │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── routes │ │ ├── +layout.server.ts │ │ ├── +layout.svelte │ │ └── +page.svelte │ ├── static │ │ └── favicon.png │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ └── vite.config.ts ├── img │ └── ui.png ├── jest.config.js ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── config │ │ ├── index.ts │ │ ├── migrations.sql │ │ └── schema.sql │ ├── index.ts │ ├── lib │ │ ├── dbUtils.ts │ │ ├── llmUtils.ts │ │ ├── logger.ts │ │ ├── repomixUtils.ts │ │ ├── utils.ts │ │ └── winstonLogger.ts │ ├── models │ │ └── types.ts │ ├── server.ts │ ├── services │ │ ├── aiService.ts │ │ ├── databaseService.ts │ │ ├── planningStateService.ts │ │ └── webSocketService.ts │ └── tools │ ├── adjustPlan.ts │ ├── markTaskComplete.ts │ ├── planFeature.ts │ └── reviewChanges.ts ├── tests │ ├── json-parser.test.ts │ ├── llmUtils.unit.test.ts │ ├── reviewChanges.integration.test.ts │ └── setupEnv.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /src/services/webSocketService.ts: -------------------------------------------------------------------------------- ```typescript import { WebSocket, WebSocketServer } from 'ws' import { UI_PORT } from '../config' import { logToFile } from '../lib/logger' import { WebSocketMessage, WebSocketMessageType, ClientRegistrationPayload, ErrorPayload, ShowQuestionPayload, QuestionResponsePayload, PlanFeatureResponseSchema, IntermediatePlanningState, } from '../models/types' import planningStateService from '../services/planningStateService' import { aiService } from '../services/aiService' import { OPENROUTER_MODEL, GEMINI_MODEL } from '../config' import { addHistoryEntry } from '../lib/dbUtils' import crypto from 'crypto' import { processAndBreakdownTasks, ensureEffortRatings, processAndFinalizePlan, } from '../lib/llmUtils' import OpenAI from 'openai' import { GenerativeModel } from '@google/generative-ai' import { z } from 'zod' import { databaseService } from '../services/databaseService' interface WebSocketConnection { socket: WebSocket featureId?: string clientId?: string lastActivity: Date } class WebSocketService { private wss: WebSocketServer | null = null private connections: Map<WebSocket, WebSocketConnection> = new Map() private static instance: WebSocketService private isInitialized = false private constructor() {} /** * Returns the singleton instance of WebSocketService */ public static getInstance(): WebSocketService { if (!WebSocketService.instance) { WebSocketService.instance = new WebSocketService() } return WebSocketService.instance } /** * Initializes the WebSocket server using an existing HTTP server * * @param httpServer The Node.js HTTP server instance from Express */ public async initialize(httpServer: import('http').Server): Promise<void> { if (this.isInitialized) { await logToFile( '[WebSocketService] WebSocket server already initialized.' ) return } try { // Attach WebSocket server to the existing HTTP server this.wss = new WebSocketServer({ server: httpServer }) // Use UI_PORT for logging consistency if needed await logToFile( `[WebSocketService] WebSocket server attached to HTTP server on port ${UI_PORT}` ) this.wss.on('connection', this.handleConnection.bind(this)) this.wss.on('error', this.handleServerError.bind(this)) // Set up connection cleanup interval (runs every minute) setInterval(this.cleanupInactiveConnections.bind(this), 60000) this.isInitialized = true } catch (error) { await logToFile( `[WebSocketService] Failed to initialize WebSocket server: ${error}` ) throw error } } /** * Handles new WebSocket connections */ private handleConnection(socket: WebSocket, _request: any): void { // Create a new connection entry const connection: WebSocketConnection = { socket, lastActivity: new Date(), } this.connections.set(socket, connection) logToFile( `[WebSocketService] New client connected. Total connections: ${this.connections.size}` ) // Send a connection established message this.sendToSocket(socket, { type: 'connection_established', }) // Set up event listeners for the socket socket.on('message', (data: Buffer) => this.handleMessage(socket, data)) socket.on('close', () => this.handleDisconnect(socket)) socket.on('error', (error) => this.handleSocketError(socket, error)) } /** * Handles incoming WebSocket messages */ private handleMessage(socket: WebSocket, data: Buffer): void { try { // Update last activity timestamp const connection = this.connections.get(socket) if (connection) { connection.lastActivity = new Date() } // Parse the message const message = JSON.parse(data.toString()) as WebSocketMessage // Handle client registration if (message.type === 'client_registration' && message.payload) { this.handleClientRegistration( socket, message.payload as ClientRegistrationPayload ) return } // Handle question response if (message.type === 'question_response' && message.payload) { this.handleQuestionResponse( message.featureId || '', message.payload as QuestionResponsePayload ) return } // Log the message type logToFile(`[WebSocketService] Received message of type: ${message.type}`) // Additional message handling logic can be added here } catch (error) { logToFile(`[WebSocketService] Error handling message: ${error}`) this.sendToSocket(socket, { type: 'error', payload: { code: 'MESSAGE_PARSING_ERROR', message: 'Failed to parse incoming message', } as ErrorPayload, }) } } /** * Handles client registration messages */ private handleClientRegistration( socket: WebSocket, payload: ClientRegistrationPayload ): void { const connection = this.connections.get(socket) if (connection) { connection.featureId = payload.featureId connection.clientId = payload.clientId || `client-${Date.now()}` logToFile( `[WebSocketService] Client registered: ${connection.clientId} for feature: ${connection.featureId}` ) // Confirm registration to the client this.sendToSocket(socket, { type: 'client_registration', featureId: connection.featureId, payload: { featureId: connection.featureId, clientId: connection.clientId, }, }) } } /** * Handles socket disconnections */ private handleDisconnect(socket: WebSocket): void { const connection = this.connections.get(socket) if (connection) { logToFile( `[WebSocketService] Client disconnected: ${ connection.clientId || 'unknown' }` ) this.connections.delete(socket) } } /** * Handles socket errors */ private handleSocketError(socket: WebSocket, error: Error): void { const connection = this.connections.get(socket) logToFile( `[WebSocketService] Socket error for client ${ connection?.clientId || 'unknown' }: ${error.message}` ) // Try to send an error message to the client this.sendToSocket(socket, { type: 'error', payload: { code: 'SOCKET_ERROR', message: 'Socket error occurred', } as ErrorPayload, }) // Close the connection after an error try { socket.terminate() } catch (closeError) { logToFile(`[WebSocketService] Error closing socket: ${closeError}`) } // Remove the connection from our map this.connections.delete(socket) } /** * Handles server errors */ private async handleServerError(error: Error): Promise<void> { await logToFile( `[WebSocketService] WebSocket server error: ${error.message}` ) } /** * Cleans up inactive connections */ private cleanupInactiveConnections(): void { const now = new Date() const inactivityThreshold = 30 * 60 * 1000 // 30 minutes for (const [socket, connection] of this.connections.entries()) { const timeSinceLastActivity = now.getTime() - connection.lastActivity.getTime() if (timeSinceLastActivity > inactivityThreshold) { logToFile( `[WebSocketService] Closing inactive connection: ${ connection.clientId || 'unknown' }` ) try { socket.terminate() } catch (error) { logToFile( `[WebSocketService] Error terminating inactive socket: ${error}` ) } this.connections.delete(socket) } } } /** * Sends a message to all connected clients for a specific feature */ public broadcast(message: WebSocketMessage): void { if (!message.featureId) { logToFile('[WebSocketService] Cannot broadcast without featureId') return } let recipientCount = 0 for (const [socket, connection] of this.connections.entries()) { // Only send to clients registered for this feature if (connection.featureId === message.featureId) { this.sendToSocket(socket, message) recipientCount++ } } logToFile( `[WebSocketService] Broadcast message of type '${message.type}' to ${recipientCount} clients for feature: ${message.featureId}` ) } /** * Sends a message to a specific socket */ private sendToSocket(socket: WebSocket, message: WebSocketMessage): void { if (socket.readyState === WebSocket.OPEN) { try { socket.send(JSON.stringify(message)) } catch (error) { logToFile( `[WebSocketService] Error sending message to socket: ${error}` ) } } } /** * Gracefully shutdowns the WebSocket server */ public async shutdown(): Promise<void> { if (!this.wss) { return } await logToFile('[WebSocketService] Shutting down WebSocket server...') // Close all connections for (const [socket] of this.connections.entries()) { try { socket.terminate() } catch (error) { await logToFile( `[WebSocketService] Error terminating socket during shutdown: ${error}` ) } } this.connections.clear() // Close the server this.wss.close((error) => { if (error) { logToFile(`[WebSocketService] Error closing WebSocket server: ${error}`) } else { logToFile('[WebSocketService] WebSocket server closed successfully') } }) this.wss = null this.isInitialized = false } /** * Broadcasts a task update notification for a feature */ public notifyTasksUpdated(featureId: string, tasks: any): void { // Log tasks to help debug console.log( 'WebSocketService.notifyTasksUpdated - Task fromReview values:', tasks.map((t: any) => ({ id: t.id, fromReview: !!t.fromReview })) ) // Make sure fromReview is properly formatted for all tasks const formattedTasks = tasks.map((task: any) => ({ ...task, fromReview: task.fromReview === 1 ? true : !!task.fromReview, })) this.broadcast({ type: 'tasks_updated', featureId, payload: { tasks: formattedTasks, updatedAt: new Date().toISOString(), }, }) } /** * Broadcasts a task status change notification */ public notifyTaskStatusChanged( featureId: string, taskId: string, status: 'pending' | 'completed' | 'decomposed' ): void { this.broadcast({ type: 'status_changed', featureId, payload: { taskId, status, updatedAt: new Date().toISOString(), }, }) } /** * Broadcasts a notification when a task is created */ public notifyTaskCreated(featureId: string, task: any): void { // Make sure fromReview is properly formatted const formattedTask = { ...task, fromReview: task.fromReview === 1 ? true : !!task.fromReview, } this.broadcast({ type: 'task_created', featureId, payload: { task: formattedTask, featureId, createdAt: new Date().toISOString(), }, }) logToFile( `[WebSocketService] Broadcasted task_created for task ID: ${task.id}` ) } /** * Broadcasts a notification when a task is updated */ public notifyTaskUpdated(featureId: string, task: any): void { // Make sure fromReview is properly formatted const formattedTask = { ...task, fromReview: task.fromReview === 1 ? true : !!task.fromReview, } this.broadcast({ type: 'task_updated', featureId, payload: { task: formattedTask, featureId, updatedAt: new Date().toISOString(), }, }) logToFile( `[WebSocketService] Broadcasted task_updated for task ID: ${task.id}` ) } /** * Broadcasts a notification when a task is deleted */ public notifyTaskDeleted(featureId: string, taskId: string): void { this.broadcast({ type: 'task_deleted', featureId, payload: { taskId, featureId, deletedAt: new Date().toISOString(), }, }) logToFile( `[WebSocketService] Broadcasted task_deleted for task ID: ${taskId}` ) } /** * Sends a question to UI clients */ public sendQuestion( featureId: string, questionId: string, question: string, options?: string[], allowsText?: boolean ): void { try { if (!featureId || !questionId || !question) { logToFile( '[WebSocketService] Cannot send question: Missing required parameters' ) return } // Check if any clients are connected for this feature let featureClients = 0 for (const connection of this.connections.values()) { if (connection.featureId === featureId) { featureClients++ } } // Log if no clients are available if (featureClients === 0) { logToFile( `[WebSocketService] Warning: Sending question ${questionId} to feature ${featureId} with no connected clients` ) } this.broadcast({ type: 'show_question', featureId, payload: { questionId, question, options, allowsText, } as ShowQuestionPayload, }) logToFile( `[WebSocketService] Sent question to ${featureClients} clients for feature ${featureId}: ${question}` ) } catch (error: any) { logToFile(`[WebSocketService] Error sending question: ${error.message}`) } } /** * Requests a screenshot from UI clients */ public requestScreenshot( featureId: string, requestId: string, target?: string ): void { this.broadcast({ type: 'request_screenshot', featureId, payload: { requestId, target, }, }) } /** * Handles user responses to questions */ private async handleQuestionResponse( featureId: string, payload: QuestionResponsePayload ): Promise<void> { try { if (!featureId) { logToFile( '[WebSocketService] Cannot handle question response: Missing featureId' ) return } const { questionId, response } = payload if (!questionId) { logToFile( '[WebSocketService] Cannot handle question response: Missing questionId' ) this.broadcast({ type: 'error', featureId, payload: { code: 'INVALID_RESPONSE', message: 'Invalid response format: missing questionId', } as ErrorPayload, }) return } logToFile( `[WebSocketService] Received response to question ${questionId}: ${response}` ) // Get the stored planning state const state = await planningStateService.getStateByQuestionId(questionId) if (!state) { logToFile( `[WebSocketService] No planning state found for question ${questionId}` ) this.broadcast({ type: 'error', featureId, payload: { code: 'QUESTION_EXPIRED', message: 'The question session has expired or is invalid.', } as ErrorPayload, }) return } // Verify feature ID matches if (state.featureId !== featureId) { logToFile( `[WebSocketService] Feature ID mismatch: question belongs to ${state.featureId}, but response came from ${featureId}` ) this.broadcast({ type: 'error', featureId, payload: { code: 'FEATURE_MISMATCH', message: 'Response came from a different feature than the question.', } as ErrorPayload, }) return } // Add the response to history await addHistoryEntry(featureId, 'user', { questionId, question: state.partialResponse, response, }) // Notify UI that response is being processed this.broadcast({ type: 'status_changed', featureId, payload: { status: 'processing_response', questionId, }, }) // Resume planning/adjustment with the user's response try { const planningModel = aiService.getPlanningModel() if (!planningModel) { throw new Error('Planning model not available') } logToFile( `[WebSocketService] Resuming ${state.planningType} with user response for feature ${featureId}` ) // Fetch feature information from database await databaseService.connect() const feature = await databaseService.getFeatureById(featureId) await databaseService.close() if (!feature) { throw new Error(`Feature with ID ${featureId} not found`) } // Get previous history entries for context await databaseService.connect() const history = await databaseService.getHistoryByFeatureId( featureId, 10 ) await databaseService.close() // Extract original feature description const originalDescription = feature.description || 'Unknown feature description' // Create a comprehensive follow-up prompt with complete context let followUpPrompt = `You previously received this feature request: "${originalDescription}" When planning this feature implementation, you asked for clarification with this question: ${state.partialResponse} The user has now provided this answer to your question: "${response}" ` // Call the LLM with the comprehensive follow-up prompt if (planningModel instanceof OpenAI) { await this.processOpenRouterResumeResponse( planningModel, followUpPrompt, state, featureId, questionId ) } else { await this.processGeminiResumeResponse( planningModel, followUpPrompt, state, featureId, questionId ) } } catch (error: any) { logToFile( `[WebSocketService] Error resuming planning: ${error.message}` ) // Notify clients of the error this.broadcast({ type: 'error', featureId, payload: { code: 'RESUME_PLANNING_FAILED', message: `Failed to process your response: ${error.message}`, } as ErrorPayload, }) // Add error history entry await addHistoryEntry(featureId, 'tool_response', { tool: state.planningType === 'feature_planning' ? 'plan_feature' : 'adjust_plan', status: 'failed_after_clarification', error: error.message, }) } } catch (error: any) { logToFile( `[WebSocketService] Unhandled error in question response handler: ${error.message}` ) if (featureId) { this.broadcast({ type: 'error', featureId, payload: { code: 'INTERNAL_ERROR', message: 'An internal error occurred while processing your response.', } as ErrorPayload, }) } } } /** * Process OpenRouter response for resuming planning after clarification */ private async processOpenRouterResumeResponse( model: OpenAI, prompt: string, state: IntermediatePlanningState, featureId: string, questionId: string ): Promise<void> { try { logToFile( `[WebSocketService] Calling OpenRouter with follow-up prompt for feature ${featureId}` ) const result = await aiService.callOpenRouterWithSchema( OPENROUTER_MODEL, [{ role: 'user', content: prompt }], PlanFeatureResponseSchema, { temperature: 0.3, max_tokens: 4096 } ) if (result.success) { await this.processPlanningSuccess( result.data, model, state, featureId, questionId ) } else { throw new Error( `OpenRouter failed to generate a valid response: ${result.error}` ) } } catch (error: any) { logToFile( `[WebSocketService] Error in OpenRouter response processing: ${error.message}` ) throw error // Re-throw to be handled by the caller } } /** * Process Gemini response for resuming planning after clarification */ private async processGeminiResumeResponse( model: GenerativeModel, prompt: string, state: IntermediatePlanningState, featureId: string, questionId: string ): Promise<void> { try { logToFile( `[WebSocketService] Calling Gemini with follow-up prompt for feature ${featureId}` ) const result = await aiService.callGeminiWithSchema( GEMINI_MODEL, prompt, PlanFeatureResponseSchema, { temperature: 0.3 } ) if (result.success) { await this.processPlanningSuccess( result.data, model, state, featureId, questionId ) } else { throw new Error( `Gemini failed to generate a valid response: ${result.error}` ) } } catch (error: any) { logToFile( `[WebSocketService] Error in Gemini response processing: ${error.message}` ) throw error // Re-throw to be handled by the caller } } /** * Process successful planning result after clarification */ private async processPlanningSuccess( data: z.infer<typeof PlanFeatureResponseSchema>, model: GenerativeModel | OpenAI, state: IntermediatePlanningState, featureId: string, questionId: string ): Promise<void> { try { // Check if tasks exist before processing if (!data.tasks) { logToFile( `[WebSocketService] Error: processPlanningSuccess called but response contained clarificationNeeded instead of tasks for feature ${featureId}` ) // Optionally, you could try to handle the clarification again here, but throwing seems safer throw new Error( 'processPlanningSuccess received clarification request, expected tasks.' ) } // Process the tasks using schema data const rawPlanSteps = data.tasks.map( (task: { effort: string; description: string }) => `[${task.effort}] ${task.description}` ) // Log the result before detailed processing await addHistoryEntry(featureId, 'model', { step: 'resumed_planning_response', response: JSON.stringify(data), }) logToFile( `[WebSocketService] Got ${rawPlanSteps.length} raw tasks after clarification for feature ${featureId}` ) // Call the centralized processing function const finalTasks = await processAndFinalizePlan( rawPlanSteps, model, featureId ) logToFile( `[WebSocketService] Processed ${finalTasks.length} final tasks after clarification for feature ${featureId}` ) // Clean up the temporary state await planningStateService.clearState(questionId) // Add success history entry (notification/saving is now handled within processAndFinalizePlan) await addHistoryEntry(featureId, 'tool_response', { tool: state.planningType === 'feature_planning' ? 'plan_feature' : 'adjust_plan', status: 'completed_after_clarification', taskCount: finalTasks.length, }) logToFile( `[WebSocketService] Successfully completed ${state.planningType} after clarification for feature ${featureId}` ) } catch (error: any) { logToFile( `[WebSocketService] Error processing successful planning result: ${error.message}` ) throw error // Re-throw to be handled by the caller } } } export default WebSocketService.getInstance() ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' import * as fsSync from 'fs' import * as fs from 'fs/promises' import { logToFile } from './lib/logger' import logger from './lib/winstonLogger' import { handleMarkTaskComplete } from './tools/markTaskComplete' import { handlePlanFeature } from './tools/planFeature' import { handleReviewChanges } from './tools/reviewChanges' import { AdjustPlanInputSchema, AdjustPlanInput } from './models/types' import { adjustPlanHandler } from './tools/adjustPlan' import webSocketService from './services/webSocketService' import planningStateService from './services/planningStateService' // Re-add static imports import express, { Request, Response, NextFunction } from 'express' import path from 'path' import crypto from 'crypto' import { FEATURE_TASKS_DIR, UI_PORT } from './config' import { Task } from './models/types' import { detectClarificationRequest } from './lib/llmUtils' import { databaseService } from './services/databaseService' import { addHistoryEntry } from './lib/dbUtils' // Immediately log that we're starting up logger.info('Starting task manager server...') // --- MCP Server Setup --- logger.info('Setting up MCP Server instance...') const server = new McpServer({ name: 'task-manager-mcp', version: '0.6.3', description: 'MCP Server using Google AI SDK and repomix for planning and review.', capabilities: { tools: { listChanged: false }, }, }) logger.info('MCP Server instance created.') // --- Tool Definitions --- logger.info('Defining tools...') // New 'get_next_task' tool server.tool( 'get_next_task', { featureId: z .string() .uuid({ message: 'Valid feature ID (UUID) is required.' }), }, async (args, _extra) => { try { const { featureId } = args await logToFile( `[TaskServer] Handling get_next_task request for feature: ${featureId}` ) // 1. Read tasks for the given feature ID await databaseService.connect() const tasks = await databaseService.getTasksByFeatureId(featureId) await databaseService.close() if (tasks.length === 0) { const message = `No tasks found for feature ID ${featureId}. The feature may not exist or has not been planned yet.` await logToFile(`[TaskServer] ${message}`) return { content: [{ type: 'text', text: message }], isError: false, } } // 2. Find the first pending task in the list const nextTask = tasks.find((task) => task.status === 'pending') if (!nextTask) { const message = `All tasks have been completed for feature ${featureId}.` await logToFile(`[TaskServer] ${message}`) return { content: [{ type: 'text', text: message }], isError: false, } } // 3. Format response with task details // Include effort in the message if available const effortInfo = nextTask.effort ? ` (Effort: ${nextTask.effort})` : '' // Include parent info if this is a subtask let parentInfo = '' if (nextTask.parent_task_id) { // Find the parent task const parentTask = tasks.find((t) => t.id === nextTask.parent_task_id) if (parentTask) { const parentDesc = parentTask.description && parentTask.description.length > 30 ? parentTask.description.substring(0, 30) + '...' : parentTask.description || '' // Use empty string if description is undefined parentInfo = ` (Subtask of: "${parentDesc}")` } else { parentInfo = ` (Subtask of parent ID: ${nextTask.parent_task_id})` // Fallback if parent not found } } // Embed ID, description, effort, and parent info in the text message const message = `Next pending task (ID: ${ nextTask.id })${effortInfo}${parentInfo}: ${ nextTask.title !== nextTask.description ? `${nextTask.title}: ${nextTask.description}` : nextTask.description }` await logToFile(`[TaskServer] Found next task: ${nextTask.id}`) return { content: [{ type: 'text', text: message }], isError: false, } } catch (error: any) { const errorMsg = `Error processing get_next_task request: ${ error instanceof Error ? error.message : String(error) }` logger.error(errorMsg) await logToFile(`[TaskServer] ${errorMsg}`) return { content: [{ type: 'text', text: errorMsg }], isError: true, } } } ) // 1. Tool: mark_task_complete server.tool( 'mark_task_complete', { task_id: z.string().uuid({ message: 'Valid task ID (UUID) is required.' }), feature_id: z .string() .uuid({ message: 'Valid feature ID (UUID) is required.' }), }, async (args, _extra) => { const result = await handleMarkTaskComplete(args) // Transform the content to match SDK expected format return { content: result.content.map((item) => ({ type: item.type as 'text', text: item.text, })), isError: result.isError, } } ) // 2. Tool: plan_feature server.tool( 'plan_feature', { feature_description: z.string().min(10, { message: 'Feature description must be at least 10 characters.', }), project_path: z .string() .describe( 'The absolute path to the project directory to scan with repomix. ' ), }, async (args, _extra) => { const result = await handlePlanFeature(args) // Transform the content to match SDK expected format // Since handlePlanFeature now always returns Array<{type: 'text', text: string}> // we can use a simple map. return { content: result.content.map((item) => ({ type: 'text', text: item.text, })), isError: result.isError, } } ) // 3. Tool: review_changes server.tool( 'review_changes', { project_path: z .string() .optional() .describe( 'The absolute path to the project directory where git commands should run. Defaults to the workspace root if not provided.' ), featureId: z .string() .uuid({ message: 'Valid feature ID (UUID) is required.' }), }, async (args, _extra) => { // Pass the project_path argument to the handler const result = await handleReviewChanges({ featureId: args.featureId, project_path: args.project_path, }) // Transform the content to match SDK expected format return { content: result.content.map((item) => ({ type: item.type as 'text', text: item.text, })), isError: result.isError, } } ) // 4. Tool: adjust_plan server.tool( 'adjust_plan', { featureId: z .string() .uuid({ message: 'Valid feature ID (UUID) is required.' }), adjustment_request: z .string() .min(1, { message: 'Adjustment request cannot be empty.' }), }, async (args: AdjustPlanInput, _extra) => { const result = await adjustPlanHandler(args) return { content: [{ type: 'text', text: result.message }], isError: result.status === 'error', } } ) logger.info('Tools defined.') // --- Error Handlers --- // Add top-level error handler for synchronous errors during load process.on('uncaughtException', (error) => { // Cannot reliably use async logToFile here logger.error('Uncaught Exception:', error) // Use synchronous append for critical errors before exit try { const logDir = process.env.LOG_DIR || './logs' const logFile = process.env.LOG_FILE || `${logDir}/debug.log` fsSync.mkdirSync(logDir, { recursive: true }) fsSync.appendFileSync( logFile, `${new Date().toISOString()} - [TaskServer] FATAL: Uncaught Exception: ${ error?.message || error }\n${error?.stack || ''}\n` ) } catch (logErr) { logger.error('Error writing uncaughtException to sync log:', logErr) } process.exit(1) }) process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled Rejection at:', promise, 'reason:', reason) try { const logDir = process.env.LOG_DIR || './logs' const logFile = process.env.LOG_FILE || `${logDir}/debug.log` fsSync.mkdirSync(logDir, { recursive: true }) fsSync.appendFileSync( logFile, `${new Date().toISOString()} - [TaskServer] FATAL: Unhandled Rejection: ${reason}\n` ) } catch (logErr) { logger.error('Error writing unhandledRejection to sync log:', logErr) } process.exit(1) }) // Helper function to list all features async function listFeatures() { try { // Ensure features directory exists await fs.mkdir(FEATURE_TASKS_DIR, { recursive: true }) // Read all files in the features directory const files = await fs.readdir(FEATURE_TASKS_DIR) // Filter for task files (ending with _mcp_tasks.json) const taskFiles = files.filter((file) => file.endsWith('_mcp_tasks.json')) // Extract feature IDs from filenames const featureIds = taskFiles.map((file) => file.replace('_mcp_tasks.json', '') ) return featureIds } catch (error) { logger.error('Error listing features:', error) return [] } } // Helper function to format a task for the frontend function formatTaskForFrontend(task: any, featureId: string) { return { id: task.id, title: task.title || task.description, description: task.description, status: task.status, completed: task.status === 'completed' || Boolean(task.completed), effort: task.effort, feature_id: featureId, // Convert from snake_case to camelCase for frontend compatibility parentTaskId: task.parent_task_id, createdAt: typeof task.created_at === 'number' ? new Date(task.created_at * 1000).toISOString() : task.createdAt, updatedAt: typeof task.updated_at === 'number' ? new Date(task.updated_at * 1000).toISOString() : task.updatedAt, fromReview: Boolean(task.fromReview || task.from_review === 1), } } // --- Server Start --- async function main() { logger.info('Entering main function...') // Initialize database *before* starting other services try { logger.info('Initializing database...') await databaseService.initializeDatabase() logger.info('Database initialized successfully.') } catch (dbError) { logger.error( 'FATAL: Failed to initialize database. Server cannot start.', dbError ) process.exit(1) // Exit if database fails to initialize } await logToFile('[TaskServer] LOG: main() started.') logger.info('Main function started') try { // --- Express Server Setup --- Moved inside main, after MCP connect const app = express() const PORT = process.env.PORT || UI_PORT || 4999 // HTTP request logging middleware app.use((req: Request, res: Response, next: NextFunction) => { const start = new Date().getTime() res.on('finish', () => { const duration = new Date().getTime() - start logger.info({ method: req.method, url: req.url, status: res.statusCode, duration: `${duration}ms`, }) }) next() }) // --- MCP Server Connection --- Moved after Express init await logToFile('[TaskServer] LOG: Creating transport...') const transport = new StdioServerTransport() await logToFile('[TaskServer] LOG: Transport created.') await logToFile('[TaskServer] LOG: Connecting server to transport...') await server.connect(transport) await logToFile( '[TaskServer] LOG: MCP Task Manager Server connected and running on stdio...' ) // Setup API endpoints // Get list of features app.get('/api/features', (req: Request, res: Response) => { ;(async () => { try { const featureIds = await listFeatures() res.json(featureIds) } catch (error: any) { logger.error(`Failed to fetch features: ${error?.message || error}`) await logToFile( `[TaskServer] ERROR fetching features: ${error?.message || error}` ) res.status(500).json({ error: 'Failed to fetch features' }) } })() }) // Get tasks for a specific feature app.get('/api/tasks/:featureId', (req: Request, res: Response) => { ;(async () => { const { featureId } = req.params try { await databaseService.connect() const tasks = await databaseService.getTasksByFeatureId(featureId) await databaseService.close() // Use the helper function to format tasks const formattedTasks = tasks.map((task) => formatTaskForFrontend(task, featureId) ) res.json(formattedTasks) } catch (error: any) { logger.error( `Failed to fetch tasks for feature ${featureId}: ${ error?.message || error }` ) await logToFile( `[TaskServer] ERROR fetching tasks for feature ${featureId}: ${ error?.message || error }` ) res.status(500).json({ error: 'Failed to fetch tasks' }) } })() }) // Parse JSON bodies for API requests app.use(express.json()) // POST: Create a new task app.post('/api/tasks', (req: Request, res: Response) => { ;(async () => { try { const { featureId, description, title, effort } = req.body // Use title if description is missing const taskDescription = description || title if (!featureId || !taskDescription) { return res.status(400).json({ error: 'Missing required fields: featureId and title are required', }) } // Read existing tasks await databaseService.connect() const tasks = await databaseService.getTasksByFeatureId(featureId) // Create a new task with a UUID const now = Math.floor(Date.now() / 1000) const newTask = { id: crypto.randomUUID(), description: taskDescription, title: title || taskDescription, // Use title or derived description status: 'pending' as const, completed: false, effort: effort as 'low' | 'medium' | 'high' | undefined, feature_id: featureId, created_at: now, updated_at: now, } // Convert to DB format and add to database await databaseService.addTask(newTask) // Add the new task to the list for WS notifications tasks.push(newTask) // Notify clients via WebSocket - both general task update and specific creation event webSocketService.broadcast({ type: 'tasks_updated', featureId, payload: { tasks: tasks.map((task) => formatTaskForFrontend(task, featureId) ), updatedAt: new Date().toISOString(), }, }) // Also send a specific task created notification webSocketService.notifyTaskCreated( featureId, formatTaskForFrontend(newTask, featureId) ) await databaseService.close() res.status(201).json(formatTaskForFrontend(newTask, featureId)) } catch (error: any) { logger.error(`Failed to create task: ${error?.message || error}`) await logToFile( `[TaskServer] ERROR creating task: ${error?.message || error}` ) res.status(500).json({ error: 'Failed to create task' }) } })() }) // PUT: Update an existing task app.put('/api/tasks/:taskId', (req: Request, res: Response) => { ;(async () => { try { const { taskId } = req.params const { featureId, description, title, status, completed, effort } = req.body if (!featureId) { return res .status(400) .json({ error: 'Missing required field: featureId' }) } // Read existing tasks await databaseService.connect() // Check if task exists const task = await databaseService.getTaskById(taskId) if (!task) { await databaseService.close() return res.status(404).json({ error: 'Task not found' }) } // First determine what kind of update we need (status or details) if (status || completed !== undefined) { // Status update const newStatus = status || task.status const isCompleted = completed !== undefined ? completed : task.completed await databaseService.updateTaskStatus( taskId, newStatus, isCompleted ) } // If we have other fields to update, do that as well if (title || description || effort) { await databaseService.updateTaskDetails(taskId, { title: title, description: description, effort: effort as 'low' | 'medium' | 'high' | undefined, }) } // Get updated tasks for WebSocket notification const tasks = await databaseService.getTasksByFeatureId(featureId) const updatedTask = await databaseService.getTaskById(taskId) // Notify clients via WebSocket - both general task update and specific update event webSocketService.broadcast({ type: 'tasks_updated', featureId, payload: { tasks: tasks.map((task) => formatTaskForFrontend(task, featureId) ), updatedAt: new Date().toISOString(), }, }) // Send a specific task updated notification webSocketService.notifyTaskUpdated( featureId, formatTaskForFrontend(updatedTask!, featureId) ) // Also send a status change notification if the status was updated if (status) { webSocketService.notifyTaskStatusChanged(featureId, taskId, status) } await databaseService.close() res.json(formatTaskForFrontend(updatedTask!, featureId)) } catch (error: any) { logger.error(`Failed to update task: ${error?.message || error}`) await logToFile( `[TaskServer] ERROR updating task: ${error?.message || error}` ) res.status(500).json({ error: 'Failed to update task' }) } })() }) // DELETE: Remove a task app.delete('/api/tasks/:taskId', (req: Request, res: Response) => { ;(async () => { try { const { taskId } = req.params const { featureId } = req.query if (!featureId) { return res .status(400) .json({ error: 'Missing required query parameter: featureId' }) } // Connect to database await databaseService.connect() // Get the task before deletion for the response const task = await databaseService.getTaskById(taskId) if (!task) { await databaseService.close() return res.status(404).json({ error: 'Task not found' }) } // Delete the task const deleted = await databaseService.deleteTask(taskId) if (!deleted) { await databaseService.close() return res.status(404).json({ error: 'Failed to delete task' }) } // Get updated tasks for WebSocket notification const remainingTasks = await databaseService.getTasksByFeatureId( featureId as string ) // Notify clients via WebSocket - both general task update and specific deletion event webSocketService.broadcast({ type: 'tasks_updated', featureId: featureId as string, payload: { tasks: remainingTasks.map((task) => formatTaskForFrontend(task, featureId as string) ), updatedAt: new Date().toISOString(), }, }) // Send a specific task deleted notification webSocketService.notifyTaskDeleted(featureId as string, taskId) await databaseService.close() res.json({ message: 'Task deleted successfully', task: formatTaskForFrontend(task, featureId as string), }) } catch (error: any) { logger.error(`Failed to delete task: ${error?.message || error}`) await logToFile( `[TaskServer] ERROR deleting task: ${error?.message || error}` ) res.status(500).json({ error: 'Failed to delete task' }) } })() }) // Get pending question for a specific feature app.get( '/api/features/:featureId/pending-question', (req: Request, res: Response) => { ;(async () => { const { featureId } = req.params try { const state = await planningStateService.getStateByFeatureId( featureId ) if (state && state.partialResponse) { // Attempt to parse the stored partialResponse as JSON let parsedData: any try { parsedData = JSON.parse(state.partialResponse) } catch (parseError) { logToFile( `[TaskServer] Error parsing partialResponse JSON for feature ${featureId}: ${parseError}. Content: ${state.partialResponse}` ) res.json(null) // Cannot parse the stored state return } // Check if the parsed data contains the clarificationNeeded structure if (parsedData && parsedData.clarificationNeeded) { const clarification = parsedData.clarificationNeeded logToFile( `[TaskServer] Found pending question ${state.questionId} for feature ${featureId}` ) res.json({ questionId: state.questionId, // Use the ID from the stored state question: clarification.question, options: clarification.options, allowsText: clarification.allowsText, }) } else { logToFile( `[TaskServer] State found for feature ${featureId}, but partialResponse JSON did not contain 'clarificationNeeded'. Content: ${state.partialResponse}` ) res.json(null) // Parsed data structure unexpected } } else { logToFile( `[TaskServer] No pending question found for feature ${featureId}` ) res.json(null) // No pending question found } } catch (error: any) { logToFile( `[TaskServer] ERROR fetching pending question for feature ${featureId}: ${ error?.message || error }` ) res.status(500).json({ error: 'Failed to fetch pending question' }) } })() } ) // Default endpoint to get tasks from most recent feature app.get('/api/tasks', (req: Request, res: Response) => { ;(async () => { try { const featureIds = await listFeatures() if (featureIds.length === 0) { // Return empty array if no features exist return res.json([]) } // Sort feature IDs by creation time (using file stats) const featuresWithStats = await Promise.all( featureIds.map(async (featureId) => { const filePath = path.join( FEATURE_TASKS_DIR, `${featureId}_mcp_tasks.json` ) const stats = await fs.stat(filePath) return { featureId, mtime: stats.mtime } }) ) // Sort by most recent modification time featuresWithStats.sort( (a, b) => b.mtime.getTime() - a.mtime.getTime() ) // Get tasks for the most recent feature const mostRecentFeatureId = featuresWithStats[0].featureId await databaseService.connect() const tasks = await databaseService.getTasksByFeatureId( mostRecentFeatureId ) await databaseService.close() // Use the helper function to format tasks const formattedTasks = tasks.map((task) => formatTaskForFrontend(task, mostRecentFeatureId) ) res.json(formattedTasks) } catch (error: any) { await logToFile( `[TaskServer] ERROR fetching default tasks: ${ error?.message || error }` ) res.status(500).json({ error: 'Failed to fetch tasks' }) } })() }) // Serve static frontend files const staticFrontendPath = path.join(__dirname, 'frontend-ui') app.use(express.static(staticFrontendPath)) // Catch-all route to serve the SPA for any unmatched routes app.get('*', (req: Request, res: Response) => { res.sendFile(path.join(staticFrontendPath, 'index.html')) }) // Start the Express server and capture the HTTP server instance const httpServer = app.listen(PORT, () => { const url = `http://localhost:${PORT}` logger.info(`Frontend server running at ${url}`) }) // Initialize WebSocket service with the HTTP server instance try { await webSocketService.initialize(httpServer) await logToFile( '[TaskServer] LOG: WebSocket server attached to HTTP server.' ) } catch (wsError) { await logToFile( `[TaskServer] WARN: Failed to initialize WebSocket server: ${wsError}` ) logger.error('WebSocket server initialization failed:', wsError) // Decide if this is fatal or can continue } // Handle process termination gracefully process.on('SIGINT', async () => { await logToFile( '[TaskServer] LOG: Received SIGINT. Shutting down gracefully...' ) // Shutdown WebSocket server try { await webSocketService.shutdown() await logToFile( '[TaskServer] LOG: WebSocket server shut down successfully.' ) } catch (error) { await logToFile( `[TaskServer] ERROR: Error shutting down WebSocket server: ${error}` ) } process.exit(0) }) process.on('SIGTERM', async () => { await logToFile( '[TaskServer] LOG: Received SIGTERM. Shutting down gracefully...' ) // Shutdown WebSocket server try { await webSocketService.shutdown() await logToFile( '[TaskServer] LOG: WebSocket server shut down successfully.' ) } catch (error) { await logToFile( `[TaskServer] ERROR: Error shutting down WebSocket server: ${error}` ) } process.exit(0) }) } catch (connectError) { logger.error('CRITICAL ERROR during server.connect():', connectError) process.exit(1) } } logger.info('Script execution reaching end of top-level code.') main().catch((error) => { logger.error('CRITICAL ERROR executing main():', error) try { const logDir = process.env.LOG_DIR || './logs' const logFile = process.env.LOG_FILE || `${logDir}/debug.log` fsSync.mkdirSync(logDir, { recursive: true }) fsSync.appendFileSync( logFile, `${new Date().toISOString()} - [TaskServer] CRITICAL ERROR executing main(): ${ error?.message || error }\n${error?.stack || ''}\n` ) } catch (logErr) { logger.error('Error writing main() catch to sync log:', logErr) } process.exit(1) // Exit if main promise rejects }) ``` -------------------------------------------------------------------------------- /src/tools/planFeature.ts: -------------------------------------------------------------------------------- ```typescript import path from 'path' import crypto from 'crypto' import { Task, PlanFeatureResponseSchema, PlanningOutputSchema, PlanningTaskSchema, } from '../models/types' import { promisify } from 'util' import { aiService } from '../services/aiService' import { parseGeminiPlanResponse, extractParentTaskId, extractEffort, ensureEffortRatings, processAndBreakdownTasks, detectClarificationRequest, processAndFinalizePlan, } from '../lib/llmUtils' import { logToFile } from '../lib/logger' import fs from 'fs/promises' import { exec } from 'child_process' import * as fsSync from 'fs' import { encoding_for_model } from 'tiktoken' import webSocketService from '../services/webSocketService' import { z } from 'zod' import { GEMINI_MODEL, OPENROUTER_MODEL, WS_PORT, UI_PORT } from '../config' import { dynamicImportDefault } from '../lib/utils' import planningStateService from '../services/planningStateService' import { databaseService } from '../services/databaseService' import { addHistoryEntry } from '../lib/dbUtils' import { getCodebaseContext } from '../lib/repomixUtils' // Promisify child_process.exec for easier async/await usage const execPromise = promisify(exec) interface PlanFeatureParams { feature_description: string project_path: string } // Revert interface to only expect text content for SDK compatibility interface PlanFeatureResult { content: Array<{ type: 'text'; text: string }> isError?: boolean } // Define a standard structure for the serialized response interface PlanFeatureStandardResponse { status: 'completed' | 'awaiting_clarification' | 'error' message: string featureId: string data?: any // For clarification details or potentially first task info uiUrl?: string // Include UI URL for convenience firstTask?: { id: string description: string effort: string } } /** * Handles the plan_feature tool request */ export async function handlePlanFeature( params: PlanFeatureParams ): Promise<PlanFeatureResult> { const { feature_description, project_path } = params // Generate a unique feature ID *first* const featureId = crypto.randomUUID() // Define UI URL early const uiUrl = `http://localhost:${UI_PORT || 4999}?featureId=${featureId}` let browserOpened = false // Flag to track if browser was opened for clarification await logToFile( `[TaskServer] Handling plan_feature request: "${feature_description}" (Path: ${ project_path || 'CWD' }), Feature ID: ${featureId}` ) // Define message and isError outside the try block to ensure they are always available let message: string let isError = false let task_count: number | undefined = undefined // Keep track of task count try { // Record tool call in history *early* await addHistoryEntry(featureId, 'tool_call', { tool: 'plan_feature', params: { feature_description, project_path }, }) // Create the feature record with project_path try { await databaseService.createFeature( featureId, feature_description, project_path ) await logToFile( `[TaskServer] Created feature record with ID: ${featureId}, Project Path: ${project_path}` ) } catch (featureError) { await logToFile( `[TaskServer] Error creating feature record: ${featureError}` ) // Continue even if feature creation fails - we can recover later } const planningModel = aiService.getPlanningModel() if (!planningModel) { await logToFile( '[TaskServer] Planning model not initialized (check API key).' ) message = 'Error: Planning model not initialized. Check API Key.' isError = true // Record error in history, handling potential logging errors try { await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', isError: true, message, }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during model init failure: ${historyError}` ) } // Return the structured error object *serialized* const errorResponse: PlanFeatureStandardResponse = { status: 'error', message: message, featureId: featureId, // Include featureId even in early errors } return { content: [{ type: 'text', text: JSON.stringify(errorResponse) }], isError, } } // --- Get Codebase Context using Utility Function --- const targetDir = project_path || '.' // Keep targetDir logic const { context: codebaseContext, error: contextError } = await getCodebaseContext( targetDir, featureId // Use featureId as log context ) // Handle potential errors from getCodebaseContext if (contextError) { message = contextError // Use the user-friendly error from the utility isError = true // Record error in history, handling potential logging errors try { await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', isError: true, message, step: 'repomix_context_gathering', }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during context gathering failure: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } // Optional: Add token counting / compression logic here if needed, // operating on the returned `codebaseContext`. // This part is kept separate from the core getCodebaseContext utility for now. // ... (Token counting and compression logic could go here) // --- LLM Planning & Task Generation --- let planSteps: string[] = [] try { await logToFile('[TaskServer] Calling LLM API for planning...') const contextPromptPart = codebaseContext ? `Based on the following codebase context:\n\`\`\`\n${codebaseContext}\n\`\`\`\n\n` : 'Based on the provided feature description (no codebase context available):\n\n' // Define the structured planning prompt incorporating the new schema const structuredPlanningPrompt = `${contextPromptPart}Generate a detailed, step-by-step coding implementation plan for the feature: \"${feature_description}\". The plan should ONLY include actionable tasks a developer needs to perform within the code. Exclude steps related to project management, deployment, manual testing, documentation updates, or obtaining approvals. For each coding task, include an effort rating (low, medium, or high) based on implementation work involved. High effort tasks often require breakdown. Use these effort definitions: - Low: Simple, quick changes in one or few files, minimal logic changes. - Medium: Requires moderate development time, involves changes across several files/components, includes writing new functions/classes. - High: Significant development time, complex architectural changes, intricate algorithms, deep refactoring. **RESPONSE FORMAT:** You MUST respond with a single JSON object. **Case 1: Clarification Needed** If you require clarification before creating the plan, respond with this JSON structure: \`\`\`json {\n "clarificationNeeded": {\n "question": "Your specific question here. Be precise.", \n "options": ["Option A", "Option B"] // Optional: Provide options if helpful \n "allowsText": true // Optional: Set to false if only options are valid \n }\n }\`\`\` **Case 2: No Clarification Needed** If you DO NOT need clarification, respond with this JSON structure, containing a non-empty array of tasks: \`\`\`json {\n "tasks": [\n { "description": "Description of the first task", "effort": "low" | "medium" | "high" },\n { "description": "Description of the second task", "effort": "low" | "medium" | "high" },\n ...\n ]\n }\`\`\` **IMPORTANT:** Respond *only* with the valid JSON object conforming to one of the two cases described above. Do not include any introductory text, concluding remarks, or markdown formatting outside the JSON structure.` // Log truncated structured prompt for history await addHistoryEntry(featureId, 'model', { step: 'structured_planning_prompt', prompt: `Generate structured plan or clarification for: "${feature_description}" ${ codebaseContext ? '(with context)' : '(no context)' }...`, }) if ('chat' in planningModel) { // It's OpenRouter - Use structured output const structuredResult = await aiService.callOpenRouterWithSchema( OPENROUTER_MODEL, [{ role: 'user', content: structuredPlanningPrompt }], PlanFeatureResponseSchema, { temperature: 0.7 } ) logToFile( `[TaskServer] Structured result (OpenRouter): ${JSON.stringify( structuredResult )}` ) if (structuredResult.success) { // Check if clarification is needed if (structuredResult.data.clarificationNeeded) { logToFile( '[TaskServer] Clarification needed based on structured response.' ) const clarification = structuredResult.data.clarificationNeeded // Open the browser *now* to show the question try { logToFile(`[TaskServer] Launching UI for clarification: ${uiUrl}`) const open = await dynamicImportDefault('open') await open(uiUrl) browserOpened = true // Mark browser as opened logToFile( '[TaskServer] Browser launch for clarification initiated.' ) } catch (openError: any) { logToFile( `[TaskServer] Error launching browser for clarification: ${openError.message}` ) // Continue even if browser launch fails, WS should still work if UI is open } // Store the intermediate state const questionId = await planningStateService.storeIntermediateState( featureId, structuredPlanningPrompt, JSON.stringify(structuredResult.data), 'feature_planning' ) // Send WebSocket message webSocketService.broadcast({ type: 'show_question', featureId, payload: { questionId, question: clarification.question, options: clarification.options, allowsText: clarification.allowsText, }, }) // Record in history await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', status: 'awaiting_clarification', questionId, }) // Return structured clarification info *serialized as text* const clarificationData = { questionId: questionId, question: clarification.question, options: clarification.options, allowsText: clarification.allowsText, } const clarificationResponse: PlanFeatureStandardResponse = { status: 'awaiting_clarification', message: `Planning paused for feature ${featureId}. User clarification needed via UI (${uiUrl}). Once submitted, call 'get_next_task' with featureId '${featureId}' to retrieve the first task.`, featureId: featureId, data: clarificationData, uiUrl: uiUrl, } return { // Serialize the standard response structure content: [ { type: 'text', text: JSON.stringify(clarificationResponse) }, ], isError: false, // Not an error, just waiting } } else if (structuredResult.data.tasks) { logToFile('[TaskServer] Tasks received in structured response.') // Convert the structured response to the expected format for processing planSteps = structuredResult.data.tasks.map( (task) => `[${task.effort}] ${task.description}` ) await addHistoryEntry(featureId, 'model', { step: 'structured_planning_response', response: JSON.stringify(structuredResult.data), }) } else { // Schema validation should prevent this, but handle defensively throw new Error( 'Structured response valid but contained neither tasks nor clarification.' ) } } else { // Fallback to unstructured response if structured fails console.warn( `[TaskServer] Structured planning failed: ${structuredResult.error}. Falling back to unstructured format.` ) // Use traditional prompt and formatting const unstructuredFallbackPrompt = `${contextPromptPart}Generate a detailed, step-by-step coding implementation plan for the feature: "${feature_description}". The plan should ONLY include actionable tasks a developer needs to perform within the code. Exclude steps related to project management, deployment, manual testing, documentation updates, or obtaining approvals. For each coding task, include an effort rating (low, medium, or high) based on implementation work involved. High effort tasks often require breakdown. Use these effort definitions: - Low: Simple, quick changes in one or few files, minimal logic changes. - Medium: Requires moderate development time, involves changes across several files/components, includes writing new functions/classes. - High: Significant development time, complex architectural changes, intricate algorithms, deep refactoring. IF YOU NEED CLARIFICATION BEFORE YOU CAN PROPERLY CREATE THE PLAN: Instead of returning a task list, use the following format to ask for clarification: [CLARIFICATION_NEEDED] Your specific question here. Be precise about what information you need to proceed. Options: [Option A, Option B, Option C] (include this line only if providing multiple-choice options) MULTIPLE_CHOICE_ONLY (include this if only the listed options are valid, omit if free text is also acceptable) [END_CLARIFICATION] For example: [CLARIFICATION_NEEDED] Should the feature implementation use SQL or NoSQL database for data storage? Options: [SQL, NoSQL] [END_CLARIFICATION] IF NO CLARIFICATION IS NEEDED: **IMPORTANT: Do NOT include any introductory sentences, summaries, concluding remarks, or any text other than the formatted task list itself. The response must start *directly* with the first task (e.g., "[low] Add console log...").** Now, generate the list of coding tasks, ensuring each task strictly follows this format: [effort] Task Description` let result = await planningModel.chat.completions.create({ model: OPENROUTER_MODEL, messages: [{ role: 'user', content: unstructuredFallbackPrompt }], temperature: 0.7, }) // We need to get the text response here for the old parsing logic to work let responseText = aiService.extractTextFromResponse(result) if (responseText === null) { message = 'Error: Failed to get planning response from LLM or response was blocked. AI Agent: Do not try to call again. Simply alert the user.' isError = true // Record error in history, handling potential logging errors try { await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', isError: true, message, step: 'llm_response_processing', }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during LLM processing failure: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } // If no plan steps were extracted from structured response, try text parsing from fallback if (!planSteps.length && responseText) { logToFile( '[TaskServer] Attempting text parsing on unstructured fallback response.' ) // IMPORTANT: Ensure parseGeminiPlanResponse ONLY extracts tasks and doesn't get confused by potential JSON remnants planSteps = parseGeminiPlanResponse(responseText) if (planSteps.length > 0) { logToFile( `[TaskServer] Extracted ${planSteps.length} tasks via text parsing.` ) } else { // If still no tasks, log error and return *serialized* message = 'Error: The planning model did not return a recognizable list of tasks.' isError = true // Record error in history, handling potential logging errors try { await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', isError: true, message, step: 'response_parsing', }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during response parsing failure: ${historyError}` ) } const errorResponse: PlanFeatureStandardResponse = { status: 'error', message: message, featureId: featureId, } return { content: [ { type: 'text', text: JSON.stringify(errorResponse) }, ], isError: true, } } } } } else { // It's Gemini - Use structured output const structuredResult = await aiService.callGeminiWithSchema( GEMINI_MODEL, structuredPlanningPrompt, PlanFeatureResponseSchema, { temperature: 0.7 } ) logToFile( `[TaskServer] Structured result (Gemini): ${JSON.stringify( structuredResult )}` ) if (structuredResult.success) { // Check if clarification is needed if (structuredResult.data.clarificationNeeded) { logToFile( '[TaskServer] Clarification needed based on structured response.' ) const clarification = structuredResult.data.clarificationNeeded // Open the browser *now* to show the question try { logToFile(`[TaskServer] Launching UI for clarification: ${uiUrl}`) const open = await dynamicImportDefault('open') await open(uiUrl) browserOpened = true // Mark browser as opened logToFile( '[TaskServer] Browser launch for clarification initiated.' ) } catch (openError: any) { logToFile( `[TaskServer] Error launching browser for clarification: ${openError.message}` ) // Continue even if browser launch fails, WS should still work if UI is open } // Store the intermediate state const questionId = await planningStateService.storeIntermediateState( featureId, structuredPlanningPrompt, JSON.stringify(structuredResult.data), 'feature_planning' ) // Send WebSocket message webSocketService.broadcast({ type: 'show_question', featureId, payload: { questionId, question: clarification.question, options: clarification.options, allowsText: clarification.allowsText, }, }) // Record in history await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', status: 'awaiting_clarification', questionId, }) // Return structured clarification info *serialized as text* const clarificationData = { questionId: questionId, question: clarification.question, options: clarification.options, allowsText: clarification.allowsText, } const clarificationResponse: PlanFeatureStandardResponse = { status: 'awaiting_clarification', message: `Planning paused for feature ${featureId}. User clarification needed via UI (${uiUrl}). Once submitted, call 'get_next_task' with featureId '${featureId}' to retrieve the first task.`, featureId: featureId, data: clarificationData, uiUrl: uiUrl, } return { // Serialize the standard response structure content: [ { type: 'text', text: JSON.stringify(clarificationResponse) }, ], isError: false, // Not an error, just waiting } } else if (structuredResult.data.tasks) { logToFile('[TaskServer] Tasks received in structured response.') // Convert the structured response to the expected format for processing planSteps = structuredResult.data.tasks.map( (task) => `[${task.effort}] ${task.description}` ) await addHistoryEntry(featureId, 'model', { step: 'structured_planning_response', response: JSON.stringify(structuredResult.data), }) } else { // Schema validation should prevent this, but handle defensively throw new Error( 'Structured response valid but contained neither tasks nor clarification.' ) } } else { // Fallback to unstructured response if structured fails console.warn( `[TaskServer] Structured planning failed: ${structuredResult.error}. Falling back to unstructured format.` ) // Use traditional Gemini call const unstructuredFallbackPrompt = `${contextPromptPart}Generate a detailed, step-by-step coding implementation plan for the feature: "${feature_description}". Engineer it in the best way possible, considering all side effects and edge cases. Be extremely thorough and meticulous. The plan should ONLY include actionable tasks a developer needs to perform within the code. Exclude steps related to project management, deployment, manual testing, documentation updates, or obtaining approvals. For each coding task, include an effort rating (low, medium, or high) based on implementation work involved. High effort tasks often require breakdown. Use these effort definitions: - Low: Simple, quick changes in one or few files, minimal logic changes. - Medium: Requires moderate development time, involves changes across several files/components, includes writing new functions/classes. - High: Significant development time, complex architectural changes, intricate algorithms, deep refactoring. IF YOU NEED CLARIFICATION BEFORE YOU CAN PROPERLY CREATE THE PLAN: Instead of returning a task list, use the following format to ask for clarification: [CLARIFICATION_NEEDED] Your specific question here. Be precise about what information you need to proceed. Options: [Option A, Option B, Option C] (include this line only if providing multiple-choice options) MULTIPLE_CHOICE_ONLY (include this if only the listed options are valid, omit if free text is also acceptable) [END_CLARIFICATION] For example: [CLARIFICATION_NEEDED] Should the feature implementation use SQL or NoSQL database for data storage? Options: [SQL, NoSQL] [END_CLARIFICATION] IF NO CLARIFICATION IS NEEDED: **IMPORTANT: Do NOT include any introductory sentences, summaries, concluding remarks, or any text other than the formatted task list itself. The response must start *directly* with the first task (e.g., "[low] Add console log...").** Now, generate the list of coding tasks, ensuring each task strictly follows this format: [effort] Task Description` let result = await planningModel.generateContent({ contents: [ { role: 'user', parts: [{ text: unstructuredFallbackPrompt }] }, ], generationConfig: { temperature: 0.7, }, }) // We need to get the text response here for the old parsing logic to work let responseText = aiService.extractTextFromResponse(result) if (responseText === null) { message = 'Error: Failed to get planning response from LLM or response was blocked. AI Agent: Do not try to call again. Simply alert the user.' isError = true // Record error in history, handling potential logging errors try { await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', isError: true, message, step: 'llm_response_processing', }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during LLM processing failure: ${historyError}` ) } return { content: [{ type: 'text', text: message }], isError } } // If no plan steps were extracted from structured response, try text parsing from fallback if (!planSteps.length && responseText) { logToFile( '[TaskServer] Attempting text parsing on unstructured fallback response.' ) // IMPORTANT: Ensure parseGeminiPlanResponse ONLY extracts tasks and doesn't get confused by potential JSON remnants planSteps = parseGeminiPlanResponse(responseText) if (planSteps.length > 0) { logToFile( `[TaskServer] Extracted ${planSteps.length} tasks via text parsing.` ) } else { // If still no tasks, log error and return *serialized* message = 'Error: The planning model did not return a recognizable list of tasks.' isError = true // Record error in history, handling potential logging errors try { await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', isError: true, message, step: 'response_parsing', }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during response parsing failure: ${historyError}` ) } const errorResponse: PlanFeatureStandardResponse = { status: 'error', message: message, featureId: featureId, } return { content: [ { type: 'text', text: JSON.stringify(errorResponse) }, ], isError: true, } } } } } // Process the plan steps using the centralized function const finalTasks = await processAndFinalizePlan( planSteps, // Use the extracted/parsed plan steps planningModel, featureId ) task_count = finalTasks.length message = `Successfully planned feature '${feature_description}' with ${task_count} tasks.` logToFile(`[TaskServer] ${message}`) // Record final success in history await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', status: 'completed', taskCount: task_count, }) } catch (error: any) { message = `Error during feature planning: ${error.message}` isError = true await logToFile(`[TaskServer] ${message} Stack: ${error.stack}`) // Record error in history, handling potential logging errors try { await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', isError: true, message: error.message, step: 'planning_execution', // Indicate where the error occurred }) } catch (historyError) { console.error( `[TaskServer] Failed to add error history entry during planning execution failure: ${historyError}` ) } } } catch (outerError: any) { // Catch errors happening before LLM call (e.g., history writing) message = `[TaskServer] Unexpected error in handlePlanFeature: ${ outerError?.message || String(outerError) }` isError = true await logToFile(`${message} Stack: ${outerError?.stack}`, 'error') // Record error in history, handling potential logging errors try { await addHistoryEntry(featureId, 'tool_response', { tool: 'plan_feature', isError: true, message: outerError?.message || String(outerError), step: 'outer_catch_block', // Indicate where the error was caught errorDetails: outerError?.stack, // Include stack trace if available }) } catch (historyError: any) { // Log the failure to record history, but don't crash the main process await logToFile( `[TaskServer] Failed to record history entry for outer error: ${ historyError?.message || String(historyError) }`, 'error' ) } } // Open the UI in the browser *if not already opened for clarification* if (!browserOpened) { try { await logToFile( `[TaskServer] Planning complete/failed. Launching UI: ${uiUrl}` ) const open = await dynamicImportDefault('open') await open(uiUrl) await logToFile('[TaskServer] Browser launch initiated successfully') } catch (openError: any) { await logToFile( `[TaskServer] Error launching browser post-process: ${openError.message}` ) // Continue even if browser launch fails } } // Prepare the final return content *as standard response object* let responseData: PlanFeatureStandardResponse if (!isError && task_count && task_count > 0) { let firstTaskDesc: string | undefined let updatedTasks: Task[] = [] try { // Use databaseService instead of readTasks await databaseService.connect() updatedTasks = await databaseService.getTasksByFeatureId(featureId) await databaseService.close() if (updatedTasks.length > 0) { const firstTask = updatedTasks[0] // Format the first task for the return message firstTaskDesc = firstTask.description // Store first task desc } else { // Fallback if tasks array is somehow empty after successful planning firstTaskDesc = undefined } } catch (readError) { logToFile( `[TaskServer] Error reading tasks after finalization: ${readError}` ) // Fallback to the original message if reading fails firstTaskDesc = undefined } // Construct success response responseData = { status: 'completed', message: `Successfully planned ${task_count || 0} tasks.${ firstTaskDesc ? ' First task: "' + firstTaskDesc + '"' : '' }`, featureId: featureId, uiUrl: uiUrl, firstTask: updatedTasks.length > 0 ? { id: updatedTasks[0].id, description: updatedTasks[0].description || '', effort: updatedTasks[0].effort || 'medium', } : undefined, } } else { // Construct error or no-tasks response responseData = { status: isError ? 'error' : 'completed', // 'completed' but with 0 tasks is possible message: message, // Use the message determined earlier (could be error or success-with-0-tasks) featureId: featureId, uiUrl: uiUrl, } } // Final return structure using the standard serialized format return { content: [ { type: 'text', text: JSON.stringify(responseData), }, ], isError, // Keep isError consistent with internal state for SDK } } ``` -------------------------------------------------------------------------------- /frontend/src/routes/+page.svelte: -------------------------------------------------------------------------------- ``` <script lang="ts"> import { onMount, onDestroy } from 'svelte'; import { page } from '$app/stores'; import { fade } from 'svelte/transition'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; import { Checkbox } from '$lib/components/ui/checkbox'; import * as Select from '$lib/components/ui/select'; import { Progress } from '$lib/components/ui/progress'; import { Loader2, CornerDownLeft, CornerDownRight, Pencil, Trash2, FileText, Eye } from 'lucide-svelte'; import { writable, type Writable } from 'svelte/store'; import type { Task, WebSocketMessage, ShowQuestionPayload, QuestionResponsePayload } from '$lib/types'; import { TaskStatus, TaskEffort } from '$lib/types'; import type { Selected } from 'bits-ui'; import QuestionModal from '$lib/components/QuestionModal.svelte'; import TaskFormModal from '$lib/components/TaskFormModal.svelte'; import ImportTasksModal from '$lib/components/ImportTasksModal.svelte'; // Convert to writable stores for better state management const tasks: Writable<Task[]> = writable([]); let nestedTasks: Task[] = []; const loading: Writable<boolean> = writable(true); const error: Writable<string | null> = writable(null); let featureId: string | null = null; let features: string[] = []; let loadingFeatures = true; let ws: WebSocket | null = null; let wsStatus: 'connecting' | 'connected' | 'disconnected' = 'disconnected'; // Question modal state let showQuestionModal = false; let questionData: ShowQuestionPayload | null = null; let selectedOption = ''; let userResponse = ''; // Task form modal state let showTaskFormModal = false; let editingTask: Task | null = null; let isEditing = false; let waitingOnLLM = false; let showImportModal = false; // Reactive statement to update nestedTasks when tasks store changes $: { const taskMap = new Map<string, Task & { children: Task[] }>(); const rootTasks: Task[] = []; // Use the tasks from the store ($tasks) $tasks.forEach(task => { // Ensure the task object has the correct type including children array const taskWithChildren: Task & { children: Task[] } = { ...task, children: [] }; taskMap.set(task.id, taskWithChildren); }); $tasks.forEach(task => { const currentTask = taskMap.get(task.id)!; // Should always exist if (task.parentTaskId && taskMap.has(task.parentTaskId)) { taskMap.get(task.parentTaskId)!.children.push(currentTask); } else { rootTasks.push(currentTask); } }); nestedTasks = rootTasks; } // --- WebSocket Functions --- function connectWebSocket() { // Construct WebSocket URL (ws:// or wss:// based on protocol) const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}`; console.log(`[WS Client] Attempting to connect to ${wsUrl}...`); wsStatus = 'connecting'; ws = new WebSocket(wsUrl); ws.onopen = () => { console.log('[WS Client] WebSocket connection established.'); wsStatus = 'connected'; // Register this client for the current feature if (featureId && ws) { sendWsMessage({ type: 'client_registration', featureId: featureId, payload: { featureId: featureId, clientId: `browser-${Date.now()}` } // Basic client ID }); } }; ws.onmessage = (event) => { try { const message: WebSocketMessage = JSON.parse(event.data); console.log('[WS Client] Received message:', message); // Check if the message is for the currently viewed feature if (message.featureId && message.featureId !== featureId) { console.log('[WS Client] Ignoring message for different feature:', message.featureId); return; } switch (message.type) { case 'tasks_updated': waitingOnLLM = false; console.log(`[WS Client] Received tasks_updated for feature ${featureId}:`, message.payload.tasks); if (message.payload?.tasks && Array.isArray(message.payload.tasks) && featureId) { // Map incoming tasks using the helper function to ensure consistency const mappedTasks = message.payload.tasks.map((task: any) => mapApiTaskToClientTask(task, featureId as string) ); tasks.set(mappedTasks); // Detailed logging after update tasks.subscribe(currentTasks => { console.log('[WS Client] tasks store after set():', currentTasks); })(); // Explicitly set loading to false loading.set(false); error.set(null); // Clear any previous errors } else { console.warn('[WS Client] Invalid or missing tasks payload for tasks_updated message.'); // Optionally handle this case, e.g., set an error or leave tasks unchanged } break; case 'status_changed': console.log(`[WS Client] Received status_changed for task ${message.payload?.taskId}`); if (message.payload?.taskId && message.payload?.status) { // Map incoming status string to TaskStatus enum let newStatus: TaskStatus; switch (message.payload.status) { case 'completed': newStatus = TaskStatus.COMPLETED; break; case 'in_progress': newStatus = TaskStatus.IN_PROGRESS; break; case 'decomposed': newStatus = TaskStatus.DECOMPOSED; break; default: newStatus = TaskStatus.PENDING; break; } tasks.update(currentTasks => currentTasks.map(task => task.id === message.payload.taskId ? { ...task, status: newStatus, // Completed is true ONLY if status is COMPLETED completed: newStatus === TaskStatus.COMPLETED } : task ) ); // Status change doesn't imply general loading state change } break; case 'show_question': waitingOnLLM = false; console.log('[WS Client] Received clarification question:', message.payload); // Store question data and show modal questionData = message.payload as ShowQuestionPayload; showQuestionModal = true; // When question arrives, we should stop loading indicator loading.set(false); break; case 'error': waitingOnLLM = false; console.error('[WS Client] Received error message:', message.payload); // Display user-facing error error.set(message.payload?.message || 'Received error from server.'); // Error likely means loading is done (with an error) loading.set(false); break; case 'task_created': console.log('[WS Client] Received task_created:', message.payload); if (message.payload?.task) { // Map incoming task to our Task type const newTask = mapApiTaskToClientTask(message.payload.task, message.featureId || featureId || ''); // Add the new task to the store tasks.update(currentTasks => [...currentTasks, newTask]); // Process nested structure processNestedTasks(); } break; case 'task_updated': console.log('[WS Client] Received task_updated:', message.payload); if (message.payload?.task) { // Map incoming task to our Task type const updatedTask = mapApiTaskToClientTask(message.payload.task, message.featureId || featureId || ''); // Update the existing task in the store tasks.update(currentTasks => currentTasks.map(task => task.id === updatedTask.id ? updatedTask : task ) ); // Process nested structure processNestedTasks(); } break; case 'task_deleted': console.log('[WS Client] Received task_deleted:', message.payload); if (message.payload?.taskId) { // Remove the task from the store tasks.update(currentTasks => currentTasks.filter(task => task.id !== message.payload.taskId) ); // Process nested structure processNestedTasks(); } break; case 'connection_established': console.log('[WS Client] Server confirmed connection.'); break; case 'client_registration': console.log('[WS Client] Server confirmed registration:', message.payload); break; // Add other message type handlers if needed } } catch (e) { console.error('[WS Client] Error processing message:', e); loading.set(false); // Ensure loading is set to false on error } }; ws.onerror = (error) => { console.error('[WS Client] WebSocket error:', error); wsStatus = 'disconnected'; loading.set(false); // Ensure loading is false on WebSocket error }; ws.onclose = (event) => { console.log(`[WS Client] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason}`); wsStatus = 'disconnected'; ws = null; // Ensure loading is false when WebSocket disconnects loading.set(false); }; } function sendWsMessage(message: WebSocketMessage) { if (ws && ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(message)); console.log('[WS Client] Sent message:', message); } catch (e) { console.error('[WS Client] Error sending message:', e); } } else { console.warn('[WS Client] Cannot send message, WebSocket not open.'); } } // --- Component Lifecycle & Data Fetching --- async function fetchTasks(selectedFeatureId?: string) { loading.set(true); error.set(null); try { // Construct the API endpoint based on whether we have a featureId const endpoint = selectedFeatureId ? `/api/tasks/${selectedFeatureId}` : '/api/tasks'; const response = await fetch(endpoint); if (!response.ok) { throw new Error(`Failed to fetch tasks: ${response.statusText}`); } const data = await response.json(); // Convert API response to our Task type const mappedData = data.map((task: any) => { // Map incoming status string to TaskStatus enum let status: TaskStatus; switch (task.status) { case 'completed': status = TaskStatus.COMPLETED; break; case 'in_progress': status = TaskStatus.IN_PROGRESS; break; case 'decomposed': status = TaskStatus.DECOMPOSED; break; default: status = TaskStatus.PENDING; break; } // Ensure effort is one of our enum values let effort: TaskEffort = TaskEffort.MEDIUM; // Default if (task.effort === 'low') { effort = TaskEffort.LOW; } else if (task.effort === 'high') { effort = TaskEffort.HIGH; } // Derive title from description if not present const title = task.title || task.description; // Ensure completed flag is consistent with status const completed = status === TaskStatus.COMPLETED; // Return the fully mapped task return { id: task.id, title, description: task.description, status, completed, effort, feature_id: task.feature_id || selectedFeatureId || undefined, parentTaskId: task.parentTaskId, createdAt: task.createdAt, updatedAt: task.updatedAt, fromReview: task.fromReview } as Task; }); tasks.set(mappedData); if (mappedData.length === 0) { error.set('No tasks found for this feature.'); } } catch (err) { error.set(err instanceof Error ? err.message : 'An error occurred'); // Add more detailed logging console.error('[fetchTasks] Error fetching tasks:', err); if (err instanceof Error && err.cause) { console.error('[fetchTasks] Error Cause:', err.cause); } tasks.set([]); // Clear any previous tasks } finally { // Always reset loading state when fetch completes loading.set(false); } } async function fetchFeatures() { loadingFeatures = true; try { const response = await fetch('/api/features'); if (!response.ok) { throw new Error('Failed to fetch features'); } features = await response.json(); } catch (err) { console.error('Error fetching features:', err); features = []; } finally { loadingFeatures = false; } } // New function to fetch pending question async function fetchPendingQuestion(id: string): Promise<ShowQuestionPayload | null> { console.log(`[Pending Question] Checking for feature ${id}...`); try { const response = await fetch(`/api/features/${id}/pending-question`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data: ShowQuestionPayload | null = await response.json(); if (data) { console.log('[Pending Question] Found pending question:', data); return data; } else { console.log('[Pending Question] No pending question found.'); return null; } } catch (err) { console.error('[Pending Question] Error fetching pending question:', err); error.set(err instanceof Error ? `Error checking for pending question: ${err.message}` : 'An error occurred while checking for pending questions.'); return null; } } function processNestedTasks() { // Define the type for map values explicitly type TaskWithChildren = Task & { children: Task[] }; const taskMap = new Map<string, TaskWithChildren>( $tasks.map(task => [task.id, { ...task, children: [] }]) ); const rootTasks: Task[] = []; taskMap.forEach((task: TaskWithChildren) => { if (task.parentTaskId && taskMap.has(task.parentTaskId)) { const parent = taskMap.get(task.parentTaskId); if (parent) { parent.children.push(task); } else { rootTasks.push(task); } } else { rootTasks.push(task); } }); // Optional: Sort root tasks or children if needed // rootTasks.sort(...); // taskMap.forEach(task => task.children.sort(...)); nestedTasks = rootTasks; } async function addTask(taskData: { title: string; effort: string; featureId: string, description?: string }) { try { const response = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...taskData, description: taskData.description || taskData.title // Use provided description, or title if none }) }); if (!response.ok) { throw new Error(`Failed to create task: ${response.statusText}`); } const newTask = await response.json(); console.log('[Task] New task created:', newTask); // Refresh the tasks list await fetchTasks(taskData.featureId); // Clear any errors that might have been shown error.set(null); } catch (err) { console.error('[Task] Error creating task:', err); error.set(err instanceof Error ? err.message : 'Failed to create task'); } } function handleTaskFormSubmit(event: CustomEvent) { const taskData = event.detail; if (isEditing && editingTask) { updateTask(editingTask.id, taskData); } else { addTask(taskData); } showTaskFormModal = false; isEditing = false; editingTask = null; } function openEditTaskModal(task: Task) { editingTask = task; isEditing = true; showTaskFormModal = true; } async function updateTask(taskId: string, taskData: { title: string; effort: string; featureId: string }) { try { const response = await fetch(`/api/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...taskData, description: taskData.title, // Use title as description featureId: taskData.featureId }) }); if (!response.ok) { throw new Error(`Failed to update task: ${response.statusText}`); } const updatedTask = await response.json(); console.log('[Task] Task updated:', updatedTask); // Refresh the tasks list await fetchTasks(taskData.featureId); // Clear any errors that might have been shown error.set(null); } catch (err) { console.error('[Task] Error updating task:', err); error.set(err instanceof Error ? err.message : 'Failed to update task'); } } async function deleteTask(taskId: string, featureId: string) { if (!confirm('Are you sure you want to delete this task?')) { return; } try { const response = await fetch(`/api/tasks/${taskId}?featureId=${featureId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error(`Failed to delete task: ${response.statusText}`); } console.log('[Task] Task deleted:', taskId); // Refresh the tasks list await fetchTasks(featureId); // Clear any errors that might have been shown error.set(null); } catch (err) { console.error('[Task] Error deleting task:', err); error.set(err instanceof Error ? err.message : 'Failed to delete task'); } } onMount(async () => { loading.set(true); // Set loading true at the start error.set(null); // Reset error // Extract featureId from URL query parameters featureId = $page.url.searchParams.get('featureId'); // Fetch available features first await fetchFeatures(); // Determine the featureId to use (from URL or latest) if (!featureId && features.length > 0) { // Attempt to fetch default tasks to find the latest featureId await fetchTasks(); if ($tasks.length > 0 && $tasks[0]?.feature_id) { featureId = $tasks[0].feature_id; console.log(`[onMount] Determined latest featureId: ${featureId}`); } else { console.log('[onMount] Could not determine latest featureId from default tasks.'); // If no featureId determined, use the first from the list if available if (features.length > 0) { featureId = features[0]; console.log(`[onMount] Using first available featureId: ${featureId}`); } } } // Now, if we have a featureId, check for pending questions and fetch tasks if (featureId) { console.log(`[onMount] Operating with featureId: ${featureId}`); // Check for pending question first const pendingQuestion = await fetchPendingQuestion(featureId); if (pendingQuestion) { questionData = pendingQuestion; showQuestionModal = true; // Still fetch tasks even if question is shown, they might exist await fetchTasks(featureId); } else { // No pending question, just fetch tasks await fetchTasks(featureId); } } else { // No featureId could be determined console.log('[onMount] No featureId available.'); if (!$error) { // Only set error if fetchTasks didn't already set one error.set('No features found. Create a feature first using the task manager CLI.'); } tasks.set([]); // Ensure tasks are empty nestedTasks = []; } // Connect WebSocket AFTER initial data load and featureId determination if (featureId) { connectWebSocket(); } }); onDestroy(() => { // Clean up WebSocket connection if (ws) { console.log('[WS Client] Closing WebSocket connection.'); ws.close(); ws = null; } }); async function toggleTaskStatus(taskId: string) { const tasksList = $tasks; const taskIndex = tasksList.findIndex((t) => t.id === taskId); if (taskIndex !== -1) { const task = tasksList[taskIndex]; const newStatus = task.status === TaskStatus.COMPLETED ? TaskStatus.PENDING : TaskStatus.COMPLETED; try { // Make API call to update status in backend const response = await fetch(`/api/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ featureId: task.feature_id, status: newStatus, completed: newStatus === TaskStatus.COMPLETED }) }); if (!response.ok) { throw new Error('Failed to update task status'); } // Optionally update local store or rely on WebSocket update } catch (err) { console.error('Failed to update task status:', err); // Optionally show error to user } } } function getEffortBadgeVariant(effort: string) { switch (effort) { case TaskEffort.LOW: return 'secondary'; case TaskEffort.MEDIUM: return 'default'; case TaskEffort.HIGH: return 'destructive'; default: return 'outline'; } } function getStatusBadgeVariant(status: TaskStatus): 'default' | 'secondary' | 'destructive' | 'outline' { switch (status) { case TaskStatus.COMPLETED: return 'secondary'; case TaskStatus.IN_PROGRESS: return 'default'; case TaskStatus.DECOMPOSED: return 'outline'; case TaskStatus.PENDING: default: return 'outline'; } } function refreshTasks() { if ($loading) return; console.log('[Task List] Refreshing tasks...'); fetchTasks(featureId || undefined); } function handleFeatureChange(selectedItem: Selected<string> | undefined) { const newFeatureId = selectedItem?.value; // Safely get value if (newFeatureId && newFeatureId !== featureId) { featureId = newFeatureId; // Update URL const url = new URL(window.location.href); url.searchParams.set('featureId', newFeatureId); window.history.pushState({}, '', url); // Fetch tasks for the new feature fetchTasks(newFeatureId); // Re-register WebSocket for the new feature if (ws && wsStatus === 'connected') { sendWsMessage({ type: 'client_registration', featureId: featureId, payload: { featureId: featureId, clientId: `browser-${Date.now()}` } }); } } } // Handle user response to clarification question function handleQuestionResponse(event: SubmitEvent) { event.preventDefault(); console.log('[WS Client] User responded to question. Selected:', selectedOption, 'Text:', userResponse); if (questionData && featureId) { const response = selectedOption || userResponse; sendWsMessage({ type: 'question_response', featureId, payload: { questionId: questionData.questionId, response: response } as QuestionResponsePayload }); showQuestionModal = false; questionData = null; waitingOnLLM = true; // Reset form fields selectedOption = ''; userResponse = ''; } } // Handle user cancellation of question function handleQuestionCancel() { console.log('[WS Client] User cancelled question'); showQuestionModal = false; questionData = null; } // ... reactive variables ... // Filter out decomposed tasks from progress calculation $: actionableTasks = $tasks.filter(t => t.status !== TaskStatus.DECOMPOSED); $: completedCount = actionableTasks.filter(t => t.completed).length; $: totalActionableTasks = actionableTasks.length; $: progress = totalActionableTasks > 0 ? (completedCount / totalActionableTasks) * 100 : 0; $: firstPendingTaskIndex = $tasks.findIndex(t => t.status === TaskStatus.PENDING); $: selectedFeatureLabel = features.find(f => f === featureId) || 'Select Feature'; // Call processNestedTasks whenever the raw tasks array changes $: { if ($tasks) { processNestedTasks(); } } // Helper function to map API task response to client Task type function mapApiTaskToClientTask(apiTask: any, currentFeatureId: string): Task { // Map incoming status string to TaskStatus enum let status: TaskStatus; switch (apiTask.status) { case 'completed': status = TaskStatus.COMPLETED; break; case 'in_progress': status = TaskStatus.IN_PROGRESS; break; case 'decomposed': status = TaskStatus.DECOMPOSED; break; default: status = TaskStatus.PENDING; break; } // Ensure effort is one of our enum values let effort: TaskEffort = TaskEffort.MEDIUM; // Default if (apiTask.effort === 'low') { effort = TaskEffort.LOW; } else if (apiTask.effort === 'high') { effort = TaskEffort.HIGH; } // Derive title from description if not present const title = apiTask.title || apiTask.description; // Ensure completed flag is consistent with status const completed = status === TaskStatus.COMPLETED; // Return the fully mapped task return { id: apiTask.id, title, description: apiTask.description, status, completed, effort, feature_id: apiTask.feature_id || currentFeatureId, parentTaskId: apiTask.parentTaskId, createdAt: apiTask.createdAt, updatedAt: apiTask.updatedAt, fromReview: apiTask.fromReview } as Task; } async function handleImportTasks(event: CustomEvent) { const { tasks } = event.detail; if (!Array.isArray(tasks)) return; for (const t of tasks) { await addTask({ title: t.title, effort: t.effort, featureId: featureId || '', description: t.description }); } showImportModal = false; } function handleCancelImport() { showImportModal = false; } </script> <div class="container mx-auto py-10 px-4 sm:px-6 lg:px-8 max-w-5xl"> <div class="flex justify-between items-center mb-8"> <h1 class="text-3xl font-bold tracking-tight text-foreground">Task Manager</h1> {#if features.length > 0} <div class="w-64"> <Select.Root onSelectedChange={handleFeatureChange} selected={featureId ? { value: featureId, label: featureId } : undefined} disabled={loadingFeatures} > <Select.Trigger class="w-full"> {featureId ? featureId.substring(0, 8) + '...' : 'Select Feature'} </Select.Trigger> <Select.Content> <Select.Group> <Select.GroupHeading>Available Features</Select.GroupHeading> {#each features as feature} <Select.Item value={feature} label={feature}>{feature.substring(0, 8)}...</Select.Item> {/each} </Select.Group> </Select.Content> </Select.Root> </div> {/if} </div> {#if questionData} <div class="flex flex-col items-center justify-center min-h-[300px]"> <div class="max-w-md w-full bg-background border border-border rounded-lg shadow-lg p-6"> <h2 class="text-xl font-semibold mb-4">Clarification Needed</h2> <p class="text-foreground mb-5">{questionData.question}</p> <form on:submit|preventDefault={handleQuestionResponse}> {#if questionData.options && questionData.options.length > 0} <div class="flex flex-col gap-3 mb-5"> {#each questionData.options as option} <label class="flex items-center gap-2 p-3 border border-border rounded-md cursor-pointer hover:bg-muted transition-colors"> <input type="radio" name="option" value={option} bind:group={selectedOption} class="focus:ring-primary" /> <span class="text-foreground">{option}</span> </label> {/each} </div> {/if} {#if questionData.allowsText !== false} <div class="mb-5"> <label for="text-response" class="block mb-2 font-medium text-foreground"> {questionData.options && questionData.options.length > 0 ? 'Or provide a custom response:' : 'Your response:'} </label> <textarea id="text-response" rows="3" bind:value={userResponse} placeholder="Type your response here..." class="w-full p-3 border border-border rounded-md resize-y text-foreground bg-background focus:ring-primary focus:border-primary" ></textarea> </div> {/if} <div class="flex justify-end gap-3 pt-2"> <button type="submit" class="bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-md font-medium text-sm disabled:opacity-50" disabled={!userResponse && (!questionData.options || !selectedOption)} > Submit Response </button> </div> </form> </div> </div> {:else if waitingOnLLM} <div class="flex flex-col items-center justify-center min-h-[300px]"> <Loader2 class="h-12 w-12 animate-spin text-primary mb-4" /> <p class="text-lg text-muted-foreground">Waiting on LLM to plan after clarification...</p> </div> {:else if $loading} <div class="flex justify-center items-center h-64"> <Loader2 class="h-12 w-12 animate-spin text-primary" /> </div> {:else if $error} <Card class="mb-6 border-destructive"> <CardHeader> <CardTitle class="text-destructive">Error Loading Tasks</CardTitle> <CardDescription class="text-destructive/90">{$error}</CardDescription> </CardHeader> </Card> {:else} <Card class="shadow-lg"> <CardHeader class="border-b border-border px-6 py-4"> <CardTitle class="text-xl font-semibold flex justify-between items-center"> <span class="flex-1">Tasks</span> <div class="flex justify-between items-center gap-4 items-center"> <Badge variant="secondary">{$tasks.length}</Badge> <Button on:click={() => showImportModal = true}>Import Tasks</Button> </div> </CardTitle> <CardDescription class="mt-1"> Manage your tasks and track progress for the selected feature. </CardDescription> <div class="pt-4"> <Progress value={progress} class="w-full h-2 [&>div]:bg-green-500 [&>div]:transition-all [&>div]:duration-300 [&>div]:ease-in-out" /> </div> </CardHeader> <CardContent class="p-0"> <div class="divide-y divide-border"> {#each nestedTasks as task (task.id)} {@const taskIndexInFlatList = $tasks.findIndex(t => t.id === task.id)} {@const isNextPending = taskIndexInFlatList === firstPendingTaskIndex} {@const isInProgress = task.status === TaskStatus.IN_PROGRESS} {@const areAllChildrenComplete = task.children && task.children.length > 0 && task.children.every(c => c.status === TaskStatus.COMPLETED)} <div transition:fade={{ duration: 200 }} class="task-row flex items-start space-x-4 p-4 hover:bg-muted/50 transition-colors {isNextPending ? 'bg-muted/30' : ''} {isInProgress ? 'in-progress-shine relative overflow-hidden' : ''} {(task.status === TaskStatus.COMPLETED || (task.status === TaskStatus.DECOMPOSED && areAllChildrenComplete)) ? 'opacity-60' : ''} {task.fromReview ? 'from-review-task' : ''}" > {#if task.status === TaskStatus.DECOMPOSED} <div class="flex items-center justify-center h-6 w-6 mt-1 text-muted-foreground"> <CornerDownRight class="h-4 w-4" /> </div> {:else} <div class="flex flex-col items-center gap-1"> <Checkbox id={`task-${task.id}`} checked={task.completed} onCheckedChange={() => toggleTaskStatus(task.id)} aria-labelledby={`task-label-${task.id}`} disabled={task.status === TaskStatus.IN_PROGRESS} /> {#if task.fromReview} <span class="review-indicator" title="Task from review"> <Eye size={20} /> </span> {/if} </div> {/if} <div class="flex-1 grid gap-1"> <div class="flex items-center gap-2"> <label for={`task-${task.id}`} id={`task-label-${task.id}`} class={`font-medium cursor-pointer ${(task.status === TaskStatus.COMPLETED || (task.status === TaskStatus.DECOMPOSED && areAllChildrenComplete)) ? 'line-through text-muted-foreground' : ''}`} > {task.title} </label> </div> {#if task.description && task.description !== task.title} <p class="text-sm text-muted-foreground"> {task.description} </p> {/if} </div> <div class="flex flex-col gap-1.5 items-end min-w-[100px]"> <div class="flex items-center gap-1.5"> <Badge variant={getStatusBadgeVariant(task.status)} class="capitalize"> {task.status.replace('_', ' ')} </Badge> </div> {#if task.effort} <Badge variant={getEffortBadgeVariant(task.effort)} class="capitalize"> {task.effort} </Badge> {/if} </div> <div class="flex gap-1 ml-4"> <button class="text-muted-foreground hover:text-foreground p-1 rounded-sm hover:bg-muted transition-colors" title="Edit task" on:click|stopPropagation={() => openEditTaskModal(task)} > <Pencil size={16} /> </button> <button class="text-muted-foreground hover:text-destructive p-1 rounded-sm hover:bg-muted transition-colors" title="Delete task" on:click|stopPropagation={() => deleteTask(task.id, featureId || '')} > <Trash2 size={16} /> </button> </div> </div> {#if task.children && task.children.length > 0} <div class="ml-10 pl-4 py-2 border-l border-border divide-y divide-border"> {#each task.children as childTask (childTask.id)} {@const childTaskIndexInFlatList = $tasks.findIndex(t => t.id === childTask.id)} {@const isChildNextPending = childTaskIndexInFlatList === firstPendingTaskIndex} {@const isChildInProgress = childTask.status === TaskStatus.IN_PROGRESS} <div transition:fade={{ duration: 200 }} class="task-row flex items-start space-x-4 pt-3 pr-4 mb-3 {isChildNextPending ? 'bg-muted/30' : ''} {isChildInProgress ? 'in-progress-shine relative overflow-hidden' : ''} {childTask.status === TaskStatus.COMPLETED ? 'opacity-60' : ''} {childTask.fromReview ? 'from-review-task' : ''}" > {#if childTask.status === TaskStatus.DECOMPOSED} <div class="flex items-center justify-center h-6 w-6 mt-1 text-muted-foreground"> <CornerDownRight class="h-4 w-4" /> </div> {:else} <div class="flex flex-col items-center gap-1"> <Checkbox id={`task-${childTask.id}`} checked={childTask.completed} onCheckedChange={() => toggleTaskStatus(childTask.id)} aria-labelledby={`task-label-${childTask.id}`} disabled={childTask.status === TaskStatus.IN_PROGRESS} /> {#if childTask.fromReview} <span class="review-indicator" title="Task from review"> <Eye size={20} /> </span> {/if} </div> {/if} <div class="flex-1 grid gap-1"> <div class="flex items-center gap-2"> <label for={`task-${childTask.id}`} id={`task-label-${childTask.id}`} class={`font-medium cursor-pointer ${childTask.status === TaskStatus.COMPLETED ? 'line-through text-muted-foreground' : ''}`} > {childTask.title} </label> </div> {#if childTask.description && childTask.description !== childTask.title} <p class="text-sm text-muted-foreground"> {childTask.description} </p> {/if} </div> <div class="flex flex-col gap-1.5 items-end min-w-[100px]"> <div class="flex items-center gap-1.5"> <Badge variant={getStatusBadgeVariant(childTask.status)} class="capitalize"> {childTask.status.replace('_', ' ')} </Badge> </div> {#if childTask.effort} <Badge variant={getEffortBadgeVariant(childTask.effort)} class="capitalize"> {childTask.effort} </Badge> {/if} </div> <div class="flex gap-1 ml-4"> <button class="text-muted-foreground hover:text-foreground p-1 rounded-sm hover:bg-muted transition-colors" title="Edit subtask" on:click|stopPropagation={() => openEditTaskModal(childTask)} > <Pencil size={16} /> </button> <button class="text-muted-foreground hover:text-destructive p-1 rounded-sm hover:bg-muted transition-colors" title="Delete subtask" on:click|stopPropagation={() => deleteTask(childTask.id, featureId || '')} > <Trash2 size={16} /> </button> </div> </div> {/each} </div> {/if} {:else} <div class="text-center py-8 text-muted-foreground"> No tasks found for this feature. </div> {/each} </div> </CardContent> <CardFooter class="flex flex-col items-start gap-4 px-6 py-4 border-t border-border"> <div class="w-full flex justify-between items-center"> <span class="text-sm text-muted-foreground"> {completedCount} of {totalActionableTasks} actionable tasks completed </span> <div class="flex gap-2"> <Button variant="outline" size="sm" on:click={() => showTaskFormModal = true} disabled={!featureId}> Add Task </Button> <Button variant="outline" size="sm" on:click={refreshTasks} disabled={$loading}> {#if $loading} <Loader2 class="mr-2 h-4 w-4 animate-spin" /> {/if} Refresh </Button> </div> </div> </CardFooter> </Card> {/if} {#if featureId} <TaskFormModal open={showTaskFormModal} featureId={featureId} isEditing={isEditing} editTask={editingTask ? { id: editingTask.id, title: editingTask.title || '', effort: editingTask.effort || 'medium' } : { id: '', title: '', effort: 'medium' }} on:submit={handleTaskFormSubmit} on:cancel={() => showTaskFormModal = false} /> {/if} <ImportTasksModal bind:open={showImportModal} on:import={handleImportTasks} on:cancel={handleCancelImport} /> </div> <style> .in-progress-shine::before { content: ''; position: absolute; top: 0; left: -100%; /* Start off-screen */ width: 75%; /* Width of the shine */ height: 100%; background: linear-gradient( 100deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.15) 50%, /* Subtle white shine */ rgba(255, 255, 255, 0) 100% ); transform: skewX(-25deg); /* Angle the shine */ animation: shine 2.5s infinite linear; /* Animation properties */ opacity: 0.8; } @keyframes shine { 0% { left: -100%; } 50%, 100% { /* Speed up the animation and make it pause less */ left: 120%; /* Move across and off-screen */ } } .task-row { position: relative; /* Needed for absolute positioning of ::before */ overflow: hidden; /* Keep shine contained */ } .review-indicator { display: inline-flex; align-items: center; justify-content: center; color: #3b82f6; transition: all 0.2s ease; margin-top: 10px; } .review-indicator:hover { opacity: 0.8; } .from-review-task { background-color: rgba(59, 130, 246, 0.08); } .from-review-task:hover { background-color: rgba(59, 130, 246, 0.12); } </style> ``` -------------------------------------------------------------------------------- /src/lib/llmUtils.ts: -------------------------------------------------------------------------------- ```typescript import { GenerateContentResult, GenerativeModel } from '@google/generative-ai' import OpenAI from 'openai' import crypto from 'crypto' import { BreakdownOptions, EffortEstimationSchema, TaskBreakdownSchema, TaskBreakdownResponseSchema, Task, TaskListSchema, HistoryEntry, FeatureHistorySchema, TaskSchema, LLMClarificationRequestSchema, } from '../models/types' import { aiService } from '../services/aiService' import { logToFile } from './logger' import { safetySettings, OPENROUTER_MODEL, GEMINI_MODEL } from '../config' import { z } from 'zod' import { encoding_for_model } from 'tiktoken' import { addHistoryEntry } from './dbUtils' import webSocketService from '../services/webSocketService' import { databaseService } from '../services/databaseService' /** * Parses the text response from Gemini into a list of tasks. */ export function parseGeminiPlanResponse( responseText: string | undefined | null ): string[] { // Basic parsing if (!responseText) { return [] } // Split by newlines and clean up const lines = responseText .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.match(/^[-*+]\s*$/)) // Process each line to remove markdown list markers and numbering const cleanedLines = lines.map((line) => { // Remove markdown list markers and numbering return line .replace(/^[-*+]\s*/, '') // Remove list markers like -, *, + .replace(/^\d+\.\s*/, '') // Remove numbered list markers like 1. 2. etc. .replace(/^[a-z]\)\s*/i, '') // Remove lettered list markers like a) b) etc. .replace(/^\([a-z]\)\s*/i, '') // Remove lettered list markers like (a) (b) etc. }) // Detect hierarchical structure based on indentation or subtask indicators const tasks: string[] = [] let currentParentTask: string | null = null for (const line of cleanedLines) { // Check if this is a parent task or a subtask based on various indicators const isSubtask = line.match(/subtask|sub-task/i) || // Contains "subtask" or "sub-task" line.startsWith(' ') || // Has leading indentation line.match(/^[a-z]\.[\d]+/i) || // Contains notation like "a.1" line.includes('→') || // Contains arrow indicators line.match(/\([a-z]\)/i) // Contains notation like "(a)" if (isSubtask && currentParentTask) { // If it's a subtask and we have a parent, tag it with the parent task info tasks.push(line) } else { // This is a new parent task currentParentTask = line tasks.push(line) } } return tasks } /** * Determines task effort using an LLM. * Uses structured JSON output for consistent results. * Works with both OpenRouter and Gemini models. */ export async function determineTaskEffort( description: string, model: GenerativeModel | OpenAI | null ): Promise<'low' | 'medium' | 'high'> { if (!model) { console.error('[TaskServer] Cannot determine effort: No model provided.') // Default to medium effort if no model is available return 'medium' } const prompt = ` Task: ${description} Analyze this **coding task** and determine its estimated **effort level** based ONLY on the implementation work involved. A higher effort level often implies the task might need breaking down into sub-steps. Use these criteria: - Low: Simple code changes likely contained in one or a few files, minimal logic changes, straightforward bug fixes. (e.g., renaming a variable, adding a console log, simple UI text change). Expected to be quick. - Medium: Requires moderate development time, involves changes across several files or components with clear patterns, includes writing new functions or small classes, moderate refactoring. Might benefit from 1-3 sub-steps. (e.g., adding a new simple API endpoint, implementing a small feature). - High: Involves significant development time, potentially spanning multiple days. Suggests complex architectural changes, intricate algorithm implementation, deep refactoring affecting multiple core components, requires careful design and likely needs breakdown into multiple sub-steps (3+). (e.g., redesigning a core system, implementing complex data processing). Exclude factors like testing procedures, documentation, deployment, or project management overhead. Respond with a JSON object that includes the effort level and optionally a short reasoning. ` try { // Use structured response with schema validation if (model instanceof OpenAI) { // Use OpenRouter with structured output const result = await aiService.callOpenRouterWithSchema( OPENROUTER_MODEL, [{ role: 'user', content: prompt }], EffortEstimationSchema, { temperature: 0.1, max_tokens: 100 } ) if (result.success) { return result.data.effort } else { console.warn( `[TaskServer] Could not determine effort using structured output: ${result.error}. Defaulting to medium.` ) return 'medium' } } else { // Use Gemini with structured output const result = await aiService.callGeminiWithSchema( GEMINI_MODEL, prompt, EffortEstimationSchema, { temperature: 0.1, maxOutputTokens: 100 } ) if (result.success) { return result.data.effort } else { console.warn( `[TaskServer] Could not determine effort using structured output: ${result.error}. Defaulting to medium.` ) return 'medium' } } } catch (error) { console.error('[TaskServer] Error determining task effort:', error) return 'medium' // Default to medium on error } } /** * Breaks down a high-effort task into subtasks using an LLM. * Uses structured JSON output for consistent results. * Works with both OpenRouter and Gemini models. */ export async function breakDownHighEffortTask( taskDescription: string, parentId: string, model: GenerativeModel | OpenAI | null, options: BreakdownOptions = {} ): Promise<string[]> { if (!model) { console.error('[TaskServer] Cannot break down task: No model provided.') return [] } // Use provided options or defaults const { minSubtasks = 2, maxSubtasks = 5, preferredEffort = 'medium', maxRetries = 3, } = options // Enhanced prompt with clearer instructions for JSON output const breakdownPrompt = ` I need to break down this high-effort coding task into smaller, actionable subtasks: Task: "${taskDescription}" Guidelines: 1. Create ${minSubtasks}-${maxSubtasks} subtasks. 2. Each subtask should ideally be '${preferredEffort}' effort, focusing on a specific part of the implementation. 3. Make each subtask a concrete coding action (e.g., "Create function X", "Refactor module Y", "Add field Z to interface"). 4. The subtasks should represent a logical sequence for implementation. 5. Only include coding tasks, not testing, documentation, or deployment steps. IMPORTANT RESPONSE FORMAT INSTRUCTIONS: - Return ONLY a valid JSON object - The JSON object MUST have a single key named "subtasks" - "subtasks" MUST be an array of objects with exactly two fields each: - "description": string - The subtask description - "effort": string - MUST be either "low" or "medium" - No other text before or after the JSON object - No markdown formatting, code blocks, or comments Example of EXACTLY how your response should be formatted: { "subtasks": [ { "description": "Create the database schema for user profiles", "effort": "medium" }, { "description": "Implement the user profile repository class", "effort": "medium" } ] } ` // Function to handle the actual API call with retry logic async function attemptBreakdown(attempt: number): Promise<string[]> { try { // Use structured response with schema validation if (model instanceof OpenAI) { // Use OpenRouter with structured output const result = await aiService.callOpenRouterWithSchema( OPENROUTER_MODEL, [{ role: 'user', content: breakdownPrompt }], TaskBreakdownResponseSchema, { temperature: 0.2 } // Lower temperature for more consistent output ) if (result.success) { // Extract the descriptions from the structured response return result.data.subtasks.map( (subtask) => `[${subtask.effort}] ${subtask.description}` ) } else { console.warn( `[TaskServer] Could not break down task using structured output (attempt ${attempt}): ${result.error}` ) // Retry if attempts remain if (attempt < maxRetries) { logToFile( `[TaskServer] Retrying task breakdown (attempt ${attempt + 1})` ) return attemptBreakdown(attempt + 1) } return [] } } else { // Use Gemini with structured output const result = await aiService.callGeminiWithSchema( GEMINI_MODEL, breakdownPrompt, TaskBreakdownResponseSchema, { temperature: 0.2 } // Lower temperature for more consistent output ) if (result.success) { // Extract the descriptions from the structured response return result.data.subtasks.map( (subtask) => `[${subtask.effort}] ${subtask.description}` ) } else { console.warn( `[TaskServer] Could not break down task using structured output (attempt ${attempt}): ${result.error}` ) // Retry if attempts remain if (attempt < maxRetries) { logToFile( `[TaskServer] Retrying task breakdown (attempt ${attempt + 1})` ) return attemptBreakdown(attempt + 1) } return [] } } } catch (error) { console.error( `[TaskServer] Error breaking down high-effort task (attempt ${attempt}):`, error ) // Retry if attempts remain if (attempt < maxRetries) { logToFile( `[TaskServer] Retrying task breakdown after error (attempt ${ attempt + 1 })` ) return attemptBreakdown(attempt + 1) } return [] } } // Start the breakdown process with first attempt return attemptBreakdown(1) } /** * Extracts parent task ID from a task description if present. * @param taskDescription The task description to check * @returns An object with the cleaned description and parentTaskId if found */ export function extractParentTaskId(taskDescription: string): { description: string parentTaskId?: string } { const parentTaskMatch = taskDescription.match(/\[parentTask:([a-f0-9-]+)\]$/i) if (parentTaskMatch) { // Extract the parent task ID const parentTaskId = parentTaskMatch[1] // Remove the parent task tag from the description const description = taskDescription.replace( /\s*\[parentTask:[a-f0-9-]+\]$/i, '' ) return { description, parentTaskId } } return { description: taskDescription } } /** * Extracts effort rating from a task description. * @param taskDescription The task description to check * @returns An object with the cleaned description and effort */ export function extractEffort(taskDescription: string): { description: string effort: 'low' | 'medium' | 'high' } { const effortMatch = taskDescription.match(/^\[(low|medium|high)\]/i) if (effortMatch) { const effort = effortMatch[1].toLowerCase() as 'low' | 'medium' | 'high' // Remove the effort tag from the description const description = taskDescription.replace( /^\[(low|medium|high)\]\s*/i, '' ) return { description, effort } } // Default to medium if no effort found return { description: taskDescription, effort: 'medium' } } /** * A more robust approach to parsing LLM-generated JSON that might be malformed due to newlines * or other common issues in AI responses. */ function robustJsonParse(text: string): any { // First attempt: Try with standard JSON.parse try { return JSON.parse(text) } catch (error: any) { // If standard parsing fails, try more aggressive fixing logToFile( `[robustJsonParse] Standard parsing failed, attempting recovery: ${error}` ) try { // Detect the main expected structure type (tasks vs subtasks) const isTasksArray = text.includes('"tasks"') const isSubtasksArray = text.includes('"subtasks"') const hasDescription = text.includes('"description"') const hasEffort = text.includes('"effort"') // Special handling for common OpenRouter/AI model response patterns if ((isTasksArray || isSubtasksArray) && hasDescription && hasEffort) { const arrayKey = isSubtasksArray ? 'subtasks' : 'tasks' // 1. Enhanced regex that works for both tasks and subtasks arrays const taskRegex = /"description"\s*:\s*"((?:[^"\\]|\\"|\\|[\s\S])*?)"\s*,\s*"effort"\s*:\s*"(low|medium|high)"/g const tasks = [] let match while ((match = taskRegex.exec(text)) !== null) { try { if (match[1] && match[2]) { tasks.push({ description: match[1].replace(/\\"/g, '"'), effort: match[2], }) } } catch (innerError) { logToFile(`[robustJsonParse] Error extracting task: ${innerError}`) } } if (tasks.length > 0) { logToFile( `[robustJsonParse] Successfully extracted ${tasks.length} ${arrayKey} with regex` ) return { [arrayKey]: tasks } } // 2. If regex extraction fails, try extracting JSON objects directly if (tasks.length === 0) { try { const objectsExtracted = extractJSONObjects(text) if (objectsExtracted.length > 0) { // Filter valid task objects const validTasks = objectsExtracted.filter( (obj) => obj && typeof obj === 'object' && obj.description && obj.effort && typeof obj.description === 'string' && typeof obj.effort === 'string' ) if (validTasks.length > 0) { logToFile( `[robustJsonParse] Successfully extracted ${validTasks.length} ${arrayKey} with object extraction` ) return { [arrayKey]: validTasks } } } } catch (objExtractionError) { logToFile( `[robustJsonParse] Object extraction failed: ${objExtractionError}` ) } } } // 3. Fall back to manual line-by-line parsing for JSON objects const lines = text.split('\n') let cleanJson = '' let inString = false for (const line of lines) { let processedLine = line // Count quote marks to track if we're inside a string for (let i = 0; i < line.length; i++) { if (line[i] === '"' && (i === 0 || line[i - 1] !== '\\')) { inString = !inString } } // Add a space instead of newline if we're in the middle of a string cleanJson += inString ? ' ' + processedLine : processedLine } // 4. Balance braces and brackets if needed cleanJson = balanceBracesAndBrackets(cleanJson) // Final attempt to parse the cleaned JSON return JSON.parse(cleanJson) } catch (recoveryError) { logToFile( `[robustJsonParse] All recovery attempts failed: ${recoveryError}` ) throw new Error(`Failed to parse JSON: ${error.message}`) } } } /** * Extracts valid JSON objects from a potentially malformed string. * Helps recover objects from truncated or malformed JSON. */ function extractJSONObjects(text: string): any[] { const objects: any[] = [] // First try to find array boundaries const arrayStartIndex = text.indexOf('[') const arrayEndIndex = text.lastIndexOf(']') if (arrayStartIndex !== -1 && arrayEndIndex > arrayStartIndex) { // Extract array content const arrayContent = text.substring(arrayStartIndex + 1, arrayEndIndex) // Split by potential object boundaries, respecting nested objects let depth = 0 let currentObject = '' let inString = false for (let i = 0; i < arrayContent.length; i++) { const char = arrayContent[i] // Track string boundaries if (char === '"' && (i === 0 || arrayContent[i - 1] !== '\\')) { inString = !inString } // Only track structure when not in a string if (!inString) { if (char === '{') { depth++ if (depth === 1) { // Start of a new object currentObject = '{' continue } } else if (char === '}') { depth-- if (depth === 0) { // End of an object, try to parse it currentObject += '}' try { const obj = JSON.parse(currentObject) objects.push(obj) } catch (e) { // If this object can't be parsed, just continue } currentObject = '' continue } } else if (char === ',' && depth === 0) { // Skip commas between objects continue } } // Add character to current object if we're inside one if (depth > 0) { currentObject += char } } } return objects } /** * Balances braces and brackets in a JSON string to make it valid */ function balanceBracesAndBrackets(text: string): string { let result = text // Count opening and closing braces/brackets const openBraces = (result.match(/\{/g) || []).length const closeBraces = (result.match(/\}/g) || []).length const openBrackets = (result.match(/\[/g) || []).length const closeBrackets = (result.match(/\]/g) || []).length // Add missing closing braces/brackets if (openBraces > closeBraces) { result += '}'.repeat(openBraces - closeBraces) } if (openBrackets > closeBrackets) { result += ']'.repeat(openBrackets - closeBrackets) } return result } /** * Parses and validates a JSON response string against a provided Zod schema. * * @param responseText - The raw JSON string from the LLM response * @param schema - The Zod schema to validate against * @returns An object containing either the validated data or error information */ export function parseAndValidateJsonResponse<T extends z.ZodType>( responseText: string | null | undefined, schema: T ): | { success: true; data: z.infer<T> } | { success: false; error: string; rawData: any | null } { // Handle null or empty responses if (!responseText) { return { success: false, error: 'Response text is empty or null', rawData: null, } } // Enhanced logging for debugging try { logToFile( `[parseAndValidateJsonResponse] Raw response text: ${responseText?.substring( 0, 1000 )}` ) } catch (logError) { // Ignore logging errors } // Extract JSON from the response if it's wrapped in markdown or other text let jsonString = responseText // Look for JSON in markdown code blocks const jsonBlockMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)\s*```/) if (jsonBlockMatch && jsonBlockMatch[1]) { jsonString = jsonBlockMatch[1] } // --- Additional cleaning: extract first valid JSON object from text --- function extractJsonFromText(text: string): string { // Remove markdown code fences text = text.replace(/```(?:json)?/gi, '').replace(/```/g, '') // Find the first { and last } const firstBrace = text.indexOf('{') const lastBrace = text.lastIndexOf('}') if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { return text.substring(firstBrace, lastBrace + 1) } return text.trim() } jsonString = extractJsonFromText(jsonString) // Try to identify expected content type for better recovery const expectsSubtasks = responseText.includes('"subtasks"') || responseText.includes('subtasks') const expectsTasks = responseText.includes('"tasks"') || responseText.includes('tasks') // --- Auto-fix common JSON issues (trailing commas, comments) --- function fixCommonJsonIssues(text: string): string { // Remove JavaScript-style comments text = text.replace(/\/\/.*$/gm, '') text = text.replace(/\/\*[\s\S]*?\*\//g, '') // Remove trailing commas in objects and arrays text = text.replace(/,\s*([}\]])/g, '$1') // Fix broken newlines in the middle of strings text = text.replace(/([^\\])"\s*\n\s*"/g, '$1') // Normalize string values that got broken across lines text = text.replace(/([^\\])"\s*\n\s*([^"])/g, '$1", "$2') // Fix incomplete JSON objects const openBraces = (text.match(/\{/g) || []).length const closeBraces = (text.match(/\}/g) || []).length if (openBraces > closeBraces) { text = text + '}'.repeat(openBraces - closeBraces) } // Fix unclosed quotes at end of string if ((text.match(/"/g) || []).length % 2 !== 0) { // Check if the last quote is an opening quote (likely in the middle of a string) const lastQuotePos = text.lastIndexOf('"') const endsWithOpenQuote = lastQuotePos !== -1 && text.substring(lastQuotePos).split('"').length === 2 if (endsWithOpenQuote) { text = text + '"' } } return text } jsonString = fixCommonJsonIssues(jsonString) // --- End auto-fix --- try { logToFile( `[parseAndValidateJsonResponse] Cleaned JSON string: ${jsonString?.substring( 0, 1000 )}` ) } catch (logError) { // Ignore logging errors } // Attempt to parse the JSON using robust parser let parsedData: any try { parsedData = robustJsonParse(jsonString) logToFile( `[parseAndValidateJsonResponse] JSON parsed successfully with robust parser` ) } catch (parseError) { // If primary parsing failed, try reconstructing specific expected structures try { // For tasks/subtasks, try to reconstruct using direct object extraction if (expectsTasks || expectsSubtasks) { const arrayKey = expectsSubtasks ? 'subtasks' : 'tasks' // Extract task objects directly from text const extractedObjects = extractJSONObjects(jsonString) if (extractedObjects.length > 0) { // Filter out invalid objects const validItems = extractedObjects.filter( (obj) => obj && typeof obj === 'object' && obj.description && obj.effort && typeof obj.description === 'string' && typeof obj.effort === 'string' ) if (validItems.length > 0) { parsedData = { [arrayKey]: validItems } logToFile( `[parseAndValidateJsonResponse] Successfully reconstructed ${arrayKey} array with ${validItems.length} items` ) // Validate against schema immediately const validationResult = schema.safeParse(parsedData) if (validationResult.success) { return { success: true, data: validationResult.data, } } } } // If we can see where tasks are, try regex extraction const regex = new RegExp( `"(description|desc|name)"\\s*:\\s*"([^"]*)"[\\s\\S]*?"(effort|difficulty)"\\s*:\\s*"(low|medium|high)"`, 'gi' ) const items = [] let match while ((match = regex.exec(responseText)) !== null) { try { items.push({ description: match[2], effort: match[4].toLowerCase(), }) } catch (e) { // Skip invalid matches } } if (items.length > 0) { parsedData = { [arrayKey]: items } logToFile( `[parseAndValidateJsonResponse] Successfully extracted ${items.length} ${arrayKey} with regex` ) // Validate against schema const validationResult = schema.safeParse(parsedData) if (validationResult.success) { return { success: true, data: validationResult.data, } } } } } catch (reconstructionError) { logToFile( `[parseAndValidateJsonResponse] Reconstruction error: ${reconstructionError}` ) // Continue to normal error handling } // All parsing methods have failed logToFile( `[parseAndValidateJsonResponse] All parsing attempts failed: ${parseError}` ) return { success: false, error: `Failed to parse JSON: ${(parseError as Error).message}`, rawData: responseText, } } // Validate against the schema const validationResult = schema.safeParse(parsedData) if (validationResult.success) { return { success: true, data: validationResult.data, } } else { logToFile( `[parseAndValidateJsonResponse] Schema validation failed. Errors: ${JSON.stringify( validationResult.error.errors )}` ) // Attempt to recover partial valid data const recoveredData = attemptPartialResponseRecovery(parsedData, schema) if (recoveredData) { logToFile( `[parseAndValidateJsonResponse] Successfully recovered partial response` ) return { success: true, data: recoveredData, } } return { success: false, error: `Schema validation failed: ${validationResult.error.message}`, rawData: parsedData, } } } /** * Attempts to recover partial valid data from a failed schema validation. * Particularly useful for array of tasks or subtasks where some items might be valid. */ function attemptPartialResponseRecovery( parsedData: any, schema: z.ZodType ): any | null { try { logToFile( `[attemptPartialResponseRecovery] Attempting to recover partial valid response` ) // Handle common case: tasks array with valid and invalid items if ( parsedData && ((parsedData.tasks && Array.isArray(parsedData.tasks)) || (parsedData.subtasks && Array.isArray(parsedData.subtasks))) ) { const isSubtasksArray = parsedData.subtasks && Array.isArray(parsedData.subtasks) const arrayKey = isSubtasksArray ? 'subtasks' : 'tasks' const items = isSubtasksArray ? parsedData.subtasks : parsedData.tasks // Filter out invalid task items const validItems = items.filter( (item: any) => item && typeof item === 'object' && item.description && item.effort && typeof item.description === 'string' && typeof item.effort === 'string' ) if (validItems.length > 0) { const recoveredData = { ...parsedData, [arrayKey]: validItems } const validationResult = schema.safeParse(recoveredData) if (validationResult.success) { logToFile( `[attemptPartialResponseRecovery] Recovery successful, found ${validItems.length} valid ${arrayKey}` ) return validationResult.data } } } return null } catch (error) { logToFile( `[attemptPartialResponseRecovery] Recovery attempt failed: ${error}` ) return null } } /** * Ensures all task descriptions have an effort rating prefix. * Determines effort using an LLM if missing. */ export async function ensureEffortRatings( taskDescriptions: string[], model: GenerativeModel | OpenAI | null ): Promise<string[]> { const effortRatedTasks: string[] = [] for (const taskDesc of taskDescriptions) { const effortMatch = taskDesc.match(/^\[(low|medium|high)\]/i) if (effortMatch) { // Ensure consistent casing const effort = effortMatch[1].toLowerCase() as 'low' | 'medium' | 'high' const cleanDesc = taskDesc.replace(/^\[(low|medium|high)\]\s*/i, '') effortRatedTasks.push(`[${effort}] ${cleanDesc}`) } else { let effort: 'low' | 'medium' | 'high' = 'medium' // Default effort try { if (model) { // Only call if model is available effort = await determineTaskEffort(taskDesc, model) } } catch (error) { console.error( `[TaskServer] Error determining effort for task "${taskDesc.substring( 0, 40 )}...". Defaulting to medium:`, error ) } effortRatedTasks.push(`[${effort}] ${taskDesc}`) } } return effortRatedTasks } /** * Processes tasks: breaks down high-effort ones, ensures effort, and creates Task objects. */ export async function processAndBreakdownTasks( initialTasksWithEffort: string[], model: GenerativeModel | OpenAI | null, featureId: string, fromReviewContext: boolean ): Promise<{ finalTasks: Task[]; complexTaskMap: Map<string, string> }> { const finalProcessedSteps: string[] = [] const complexTaskMap = new Map<string, string>() let breakdownSuccesses = 0 let breakdownFailures = 0 for (const step of initialTasksWithEffort) { const effortMatch = step.match(/^\[(low|medium|high)\]/i) const isHighEffort = effortMatch && effortMatch[1].toLowerCase() === 'high' if (isHighEffort) { const taskDescription = step.replace(/^\[high\]\s*/i, '') const parentId = crypto.randomUUID() complexTaskMap.set(taskDescription, parentId) // Map original description to ID try { await addHistoryEntry(featureId, 'model', { step: 'task_breakdown_attempt', task: step, parentId, }) const subtasks = await breakDownHighEffortTask( taskDescription, parentId, model, { minSubtasks: 2, maxSubtasks: 5, preferredEffort: 'medium' } ) if (subtasks.length > 0) { // Add parent container task (marked completed later) finalProcessedSteps.push(`${step} [parentContainer]`) // Add marker // Process and add subtasks immediately after parent // Ensure subtasks also have effort ratings const subtasksWithEffort = await ensureEffortRatings(subtasks, model) const subtasksWithParentId = subtasksWithEffort.map((subtaskDesc) => { const { description: cleanSubDesc } = extractEffort(subtaskDesc) // Already has effort return `${subtaskDesc} [parentTask:${parentId}]` }) finalProcessedSteps.push(...subtasksWithParentId) await addHistoryEntry(featureId, 'model', { step: 'task_breakdown_success', task: step, parentId, subtasks: subtasksWithParentId, }) breakdownSuccesses++ } else { // Breakdown failed, keep original high-effort task finalProcessedSteps.push(step) await addHistoryEntry(featureId, 'model', { step: 'task_breakdown_failure', task: step, }) breakdownFailures++ } } catch (breakdownError) { console.error( `[TaskServer] Error during breakdown for task "${taskDescription.substring( 0, 40 )}...":`, breakdownError ) finalProcessedSteps.push(step) // Keep original task on error await addHistoryEntry(featureId, 'model', { step: 'task_breakdown_error', task: step, error: breakdownError instanceof Error ? breakdownError.message : String(breakdownError), }) breakdownFailures++ } } else { // Keep low/medium effort tasks as is finalProcessedSteps.push(step) } } await logToFile( `[TaskServer] Breakdown processing complete: ${breakdownSuccesses} successes, ${breakdownFailures} failures.` ) // --- Create Task Objects --- const finalTasks: Task[] = [] const taskCreationErrors: string[] = [] for (const step of finalProcessedSteps) { try { const isParentContainer = step.includes('[parentContainer]') const descriptionWithTags = step.replace('[parentContainer]', '').trim() const { description: descWithoutParent, parentTaskId } = extractParentTaskId(descriptionWithTags) const { description: cleanDescription, effort } = extractEffort(descWithoutParent) // Validate effort extracted or default const validatedEffort = ['low', 'medium', 'high'].includes(effort) ? effort : 'medium' // Get the predetermined ID for parent containers, otherwise generate new const originalHighEffortDesc = isParentContainer ? cleanDescription : null const taskId = (originalHighEffortDesc && complexTaskMap.get(originalHighEffortDesc)) || crypto.randomUUID() // If it's a parent container, set status to 'decomposed', otherwise 'pending' const status = isParentContainer ? 'decomposed' : 'pending' const taskDataToValidate: Omit< Task, 'title' | 'subTasks' | 'dependencies' | 'history' | 'isManual' > = { id: taskId, feature_id: featureId, status, description: cleanDescription, effort: validatedEffort, completed: false, // All new tasks/subtasks start as not completed. ...(parentTaskId && { parentTaskId }), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...(fromReviewContext && { fromReview: true }), // Set fromReview if in review context } // --- Enhanced Logging --- logToFile( `[processAndBreakdownTasks] Preparing ${ isParentContainer ? 'Parent' : parentTaskId ? 'Subtask' : 'Task' } for validation: ID=${taskId}, Status=${status}, Parent=${ parentTaskId || 'N/A' }, Desc="${cleanDescription.substring(0, 50)}..."` ) logToFile( `[processAndBreakdownTasks] Task data before validation: ${JSON.stringify( taskDataToValidate )}` ) // --- End Enhanced Logging --- // Validate against the Task schema before pushing const validationResult = TaskSchema.safeParse(taskDataToValidate) if (validationResult.success) { // --- Enhanced Logging --- logToFile( `[processAndBreakdownTasks] Validation successful for Task ID: ${taskId}` ) // --- End Enhanced Logging --- finalTasks.push(validationResult.data) } else { // --- Enhanced Logging --- const errorMsg = `Task "${cleanDescription.substring( 0, 30 )}..." (ID: ${taskId}) failed validation: ${ validationResult.error.message }` logToFile(`[processAndBreakdownTasks] ${errorMsg}`) // --- End Enhanced Logging --- taskCreationErrors.push(errorMsg) console.warn( `[TaskServer] Task validation failed for "${cleanDescription.substring( 0, 30 )}..." (ID: ${taskId}):`, validationResult.error.flatten() ) } } catch (creationError) { const errorMsg = `Error creating task object for step "${step.substring( 0, 30 )}...": ${ creationError instanceof Error ? creationError.message : String(creationError) }` // --- Enhanced Logging --- logToFile(`[processAndBreakdownTasks] ${errorMsg}`) // --- End Enhanced Logging --- taskCreationErrors.push(errorMsg) console.error( `[TaskServer] Error creating task object for step "${step.substring( 0, 30 )}...":`, creationError ) } } if (taskCreationErrors.length > 0) { console.error( `[TaskServer] ${taskCreationErrors.length} errors occurred during task object creation/validation.` ) await addHistoryEntry(featureId, 'model', { step: 'task_creation_errors', errors: taskCreationErrors, }) // Decide if we should throw or return partial results. Returning for now. } return { finalTasks, complexTaskMap } } /** * Processes raw plan steps, ensures effort ratings are assigned, breaks down high-effort tasks, * saves the final task list, and notifies WebSocket clients of the update. * * @param rawPlanSteps Array of task descriptions (format: "[effort] description"). * @param model The generative model to use for effort estimation/task breakdown. * @param featureId The ID of the feature being planned. * @param fromReview Optional flag to set fromReview: true on all saved tasks. * @returns The final list of processed Task objects. */ export async function processAndFinalizePlan( rawPlanSteps: string[], model: GenerativeModel | OpenAI | null, featureId: string, fromReview: boolean = false // Add default value ): Promise<Task[]> { logToFile( `[TaskServer] Processing and finalizing plan for feature ${featureId}...` ) let existingTasks: Task[] = [] let finalTasks: Task[] = [] const complexTaskMap = new Map<string, string>() // To track original description of broken down tasks try { // 1. Ensure all raw steps have effort ratings. // ensureEffortRatings preserves existing [high] prefixes from rawPlanSteps // and assigns effort to those without a prefix. const initialTasksWithEffort = await ensureEffortRatings( rawPlanSteps, model ) // Explicitly define the tasks to be sent for breakdown processing. // This includes tasks from rawPlanSteps that were marked [high] // (as ensureEffortRatings preserves such tags) and will be // unconditionally processed by processAndBreakdownTasks. const tasksForBreakdownProcessing = initialTasksWithEffort // 2. Process tasks: Breakdown high-effort ones. // processAndBreakdownTasks will identify and attempt to break down tasks // with a "[high]" prefix within tasksForBreakdownProcessing. const { finalTasks: processedTasks, complexTaskMap: breakdownMap } = await processAndBreakdownTasks( tasksForBreakdownProcessing, // Using the explicitly defined variable model, featureId, // Pass featureId for logging/history fromReview // Pass the fromReview context flag ) // Merge complexTaskMap from breakdown breakdownMap.forEach((value, key) => complexTaskMap.set(key, value)) // --- Start Database Operations --- await databaseService.connect() logToFile( `[processAndFinalizePlan] Database connected. Fetching existing tasks...` ) // 3. Fetch existing tasks to compare existingTasks = await databaseService.getTasksByFeatureId(featureId) const existingTaskMap = new Map(existingTasks.map((t) => [t.id, t])) const processedTaskMap = new Map(processedTasks.map((t) => [t.id, t])) const tasksToAdd: Task[] = [] const tasksToUpdate: { id: string; updates: Partial<Task> }[] = [] const taskIdsToDelete: string[] = [] // 4. Compare processed tasks with existing tasks for (const processedTask of processedTasks) { if (existingTaskMap.has(processedTask.id)) { // Task exists, check for updates const existing = existingTaskMap.get(processedTask.id)! // updates object should only contain keys matching DB columns (snake_case) const updates: Partial< Pick<Task, 'description' | 'effort' | 'fromReview'> & { parentTaskId?: string } > = {} if (existing.description !== processedTask.description) { updates.description = processedTask.description } if (existing.effort !== processedTask.effort) { updates.effort = processedTask.effort } // Compare snake_case from DB (existing) with camelCase from processed Task if (existing.parentTaskId !== processedTask.parentTaskId) { // Add snake_case key to updates object for DB updates.parentTaskId = processedTask.parentTaskId } // Always update the 'fromReview' flag if this process is from review if (fromReview && !existing.fromReview) { // Use camelCase here as Task type expects it, DB service handles conversion to snake_case updates.fromReview = true await logToFile( `[processAndFinalizePlan] Updating task ${existing.id} to set fromReview = true. Context fromReview: ${fromReview}, existing.fromReview: ${existing.fromReview}` ) } // Check if any updates are needed using the keys in the updates object if (Object.keys(updates).length > 0) { tasksToUpdate.push({ id: processedTask.id, updates }) } } else { // New task to add tasksToAdd.push(processedTask) } } if (!fromReview) { // Identify tasks to delete (exist in DB but not in new plan) for (const existingTask of existingTasks) { if (!processedTaskMap.has(existingTask.id)) { taskIdsToDelete.push(existingTask.id) } } } // 5. Apply changes to the database logToFile( `[processAndFinalizePlan] Applying DB changes: ${tasksToAdd.length} adds, ${tasksToUpdate.length} updates, ${taskIdsToDelete.length} deletes.` ) for (const { id, updates } of tasksToUpdate) { // Check if the task being updated was decomposed const isDecomposed = complexTaskMap.has(id) if (isDecomposed) { // If decomposed, mark status as 'decomposed' and completed = true await databaseService.updateTaskStatus(id, 'decomposed', true) // Only update other details if necessary (rare for decomposed tasks) if (Object.keys(updates).length > 0) { // Pass updates object (contains snake_case key) to DB service await databaseService.updateTaskDetails(id, updates) } } else { // Otherwise, just update details // Pass updates object (contains snake_case key) to DB service await databaseService.updateTaskDetails(id, updates) } } for (const task of tasksToAdd) { // Ensure parent task exists if specified using camelCase from Task type if (task.parentTaskId) { const parentExistsInDB = existingTaskMap.has(task.parentTaskId) const parentExistsInProcessed = processedTaskMap.has(task.parentTaskId) if (!parentExistsInDB && !parentExistsInProcessed) { logToFile( `[processAndFinalizePlan] Warning: Parent task ${task.parentTaskId} for task ${task.id} not found. Setting parent to null.` ) // Use camelCase when modifying the task object task.parentTaskId = undefined } } // Prepare object for DB insertion with snake_case keys const now = Math.floor(Date.now() / 1000) const dbTaskPayload: any = { id: task.id, title: task.title, description: task.description, status: task.status, completed: task.completed ? 1 : 0, effort: task.effort, feature_id: featureId, created_at: task.createdAt && typeof task.createdAt === 'number' ? Math.floor(new Date(task.createdAt * 1000).getTime() / 1000) : now, // Map and convert updated_at: task.updatedAt && typeof task.updatedAt === 'number' ? Math.floor(new Date(task.updatedAt * 1000).getTime() / 1000) : now, // Map and convert // Use camelCase 'fromReview' to align with the Task interface expected by addTask fromReview: fromReview || task.fromReview || false, } await logToFile( `[processAndFinalizePlan] Adding task ${task.id}. Context fromReview: ${fromReview}, task.fromReview property: ${task.fromReview}, dbTaskPayload.fromReview value: ${dbTaskPayload.fromReview}`, 'debug' ) try { // Ensure that the object passed to addTask conforms to the Task interface await databaseService.addTask({ id: dbTaskPayload.id, title: dbTaskPayload.title, description: dbTaskPayload.description, status: dbTaskPayload.status, completed: dbTaskPayload.completed === 1, // Ensure boolean effort: dbTaskPayload.effort, feature_id: dbTaskPayload.feature_id, created_at: dbTaskPayload.created_at, updated_at: dbTaskPayload.updated_at, fromReview: dbTaskPayload.fromReview, // This is now correctly camelCased }) } catch (dbError) { logToFile( `[processAndFinalizePlan] Error adding task to database: ${dbError}` ) console.error(`[TaskServer] Error adding task to database:`, dbError) throw dbError } } for (const taskId of taskIdsToDelete) { await databaseService.deleteTask(taskId) } // 6. Fetch the final list of tasks after all modifications finalTasks = await databaseService.getTasksByFeatureId(featureId) logToFile( `[processAndFinalizePlan] Final task count for feature ${featureId}: ${finalTasks.length}` ) // --- End Database Operations --- } catch (error) { logToFile( `[processAndFinalizePlan] Error during plan finalization for feature ${featureId}: ${error}` ) console.error(`[TaskServer] Error during plan finalization:`, error) // Re-throw the error to be handled by the caller (e.g., tool handler) throw error } finally { // Ensure database connection is closed, even if errors occurred try { await databaseService.close() logToFile(`[processAndFinalizePlan] Database connection closed.`) } catch (closeError) { logToFile( `[processAndFinalizePlan] Error closing database connection: ${closeError}` ) console.error(`[TaskServer] Error closing database:`, closeError) } } // 7. Notify UI about the updated tasks (outside the main try/catch for DB ops) try { // Add detailed logging to debug if (finalTasks.length > 0) { await logToFile( `[processAndFinalizePlan] Sample of final task from DB (finalTasks[0]): ${JSON.stringify( finalTasks[0], null, 2 )}` ) } else { await logToFile( `[processAndFinalizePlan] No final tasks to log from DB sample.` ) } const formattedTasks = finalTasks.map((task: any) => { // Create a clean task object for the WebSocket return { id: task.id, description: task.description, status: task.status, effort: task.effort, parentTaskId: task.parentTaskId, completed: task.completed, title: task.title, fromReview: task.fromReview || task.from_review === 1, // Handle both camelCase and snake_case createdAt: typeof task.createdAt === 'number' ? new Date(task.createdAt * 1000).toISOString() : undefined, updatedAt: typeof task.updatedAt === 'number' ? new Date(task.updatedAt * 1000).toISOString() : undefined, } }) // Log the first formatted task if (formattedTasks.length > 0) { await logToFile( `[processAndFinalizePlan] First formatted task for WebSocket (formattedTasks[0]): ${JSON.stringify( formattedTasks[0], null, 2 )}`, 'debug' ) } else { await logToFile( `[processAndFinalizePlan] No formatted tasks to log for WebSocket sample.`, 'debug' ) } webSocketService.notifyTasksUpdated(featureId, formattedTasks) logToFile(`[processAndFinalizePlan] WebSocket notification sent.`) } catch (wsError) { logToFile( `[processAndFinalizePlan] Error sending WebSocket notification: ${wsError}` ) console.error(`[TaskServer] Error sending WebSocket update:`, wsError) // Do not re-throw WS errors, as the main operation succeeded } return finalTasks } /** * Detects if the LLM response contains a clarification request. * This function searches for both JSON-formatted clarification requests and * special prefix format like [CLARIFICATION_NEEDED]. * * @param responseText The raw response from the LLM * @returns An object with success flag and either the parsed clarification request or error message */ export function detectClarificationRequest( responseText: string | null | undefined ): | { detected: true clarificationRequest: z.infer<typeof LLMClarificationRequestSchema> rawResponse: string } | { detected: false; rawResponse: string | null } { if (!responseText) { return { detected: false, rawResponse: null } } // Check for [CLARIFICATION_NEEDED] format const prefixMatch = responseText.match( /\[CLARIFICATION_NEEDED\](.*?)(\[END_CLARIFICATION\]|$)/s ) if (prefixMatch) { const questionText = prefixMatch[1].trim() // Parse out options if they exist const optionsMatch = questionText.match(/Options:\s*\[(.*?)\]/) const options = optionsMatch ? optionsMatch[1].split(',').map((o) => o.trim()) : undefined // Check if text input is allowed const allowsText = !questionText.includes('MULTIPLE_CHOICE_ONLY') // Create a clarification request object return { detected: true, clarificationRequest: { type: 'clarification_needed', question: questionText .replace(/Options:\s*\[.*?\]/, '') .replace('MULTIPLE_CHOICE_ONLY', '') .trim(), options, allowsText, }, rawResponse: responseText, } } // Try to parse as JSON try { // Check if we have a JSON object in the response const jsonMatch = responseText.match(/\{[\s\S]*\}/) if (jsonMatch) { const jsonStr = jsonMatch[0] const parsedJson = JSON.parse(jsonStr) // Check if it's a clarification request if ( parsedJson.type === 'clarification_needed' || parsedJson.clarification_needed || parsedJson.needs_clarification ) { // Attempt to validate against our schema const result = LLMClarificationRequestSchema.safeParse({ type: 'clarification_needed', question: parsedJson.question || parsedJson.message || '', options: parsedJson.options || undefined, allowsText: parsedJson.allowsText !== false, }) if (result.success) { return { detected: true, clarificationRequest: result.data, rawResponse: responseText, } } } } return { detected: false, rawResponse: responseText } } catch (error) { // If JSON parsing fails, it's not a JSON-formatted clarification request return { detected: false, rawResponse: responseText } } } ```