diff --git a/backend/src/build-system/__tests__/test.model-provider.spec.ts b/backend/src/build-system/__tests__/test.model-provider.spec.ts deleted file mode 100644 index 6631449..0000000 --- a/backend/src/build-system/__tests__/test.model-provider.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { EmbeddingProvider } from 'src/common/embedding-provider'; - -describe('Model Provider Test', () => { - const embProvider = EmbeddingProvider.getInstance(); - it('should generate a response from the model provider', async () => { - const res = await embProvider.generateEmbResponse( - 'Your text string goes here', - 'text-embedding-3-small', - ); - console.log(res); - }); -}); diff --git a/backend/src/build-system/__tests__/test.spec.ts b/backend/src/build-system/__tests__/test.spec.ts deleted file mode 100644 index 5524fe3..0000000 --- a/backend/src/build-system/__tests__/test.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -// src/build-system/__tests__/project-init-sequence.spec.ts -import { BuilderContext } from '../context'; -import { BuildHandlerManager } from '../hanlder-manager'; -import { ProjectInitHandler } from '../handlers/project-init'; -import { BuildSequence } from '../types'; -describe('Project Init Handler Test', () => { - let context: BuilderContext; - let handlerManager: BuildHandlerManager; - - const testSequence: BuildSequence = { - id: 'test:project-init', - version: '1.0', - name: 'Project Init Test', - description: 'Test sequence for project initialization', - steps: [ - { - id: 'step1', - name: 'Project Setup', - parallel: false, - nodes: [ - { - id: 'op:PROJECT::STATE:SETUP', - type: 'PROJECT_SETUP', - name: 'Project Setup', - }, - ], - }, - ], - }; - - beforeEach(() => { - handlerManager = BuildHandlerManager.getInstance(); - handlerManager.clear(); - - context = new BuilderContext(testSequence, 'id'); - }); - - describe('Handler Registration', () => { - test('should register handler correctly', () => { - const handler = handlerManager.getHandler('op:PROJECT::STATE:SETUP'); - expect(handler).toBeDefined(); - expect(handler instanceof ProjectInitHandler).toBeTruthy(); - }); - }); - - describe('Direct Handler Execution', () => { - test('should be able to run handler directly', async () => { - const handler = new ProjectInitHandler(); - const result = await handler.run(context); - expect(result.success).toBe(true); - }); - }); - - describe('Handler ID', () => { - test('should have correct handler id', () => { - const handler = new ProjectInitHandler(); - expect(handler.id).toBe('op:PROJECT::STATE:SETUP'); - }); - }); -}); diff --git a/backend/src/build-system/context.ts b/backend/src/build-system/context.ts index 6afa5c7..483d51b 100644 --- a/backend/src/build-system/context.ts +++ b/backend/src/build-system/context.ts @@ -1,5 +1,6 @@ import { BuildExecutionState, + BuildHandler, BuildNode, BuildResult, BuildSequence, @@ -12,6 +13,7 @@ import { ModelProvider } from 'src/common/model-provider'; import { v4 as uuidv4 } from 'uuid'; import { BuildMonitor } from './monitor'; import { BuildHandlerManager } from './hanlder-manager'; +import { RetryHandler } from './retry-handler'; /** * Global data keys used throughout the build process @@ -50,11 +52,13 @@ export class BuilderContext { waiting: new Set(), }; + private globalPromises: Set> = new Set(); private logger: Logger; private globalContext: Map = new Map(); private nodeData: Map = new Map(); private handlerManager: BuildHandlerManager; + private retryHandler: RetryHandler; private monitor: BuildMonitor; public model: ModelProvider; public virtualDirectory: VirtualDirectory; @@ -63,6 +67,7 @@ export class BuilderContext { private sequence: BuildSequence, id: string, ) { + this.retryHandler = RetryHandler.getInstance(); this.handlerManager = BuildHandlerManager.getInstance(); this.model = ModelProvider.getInstance(); this.monitor = BuildMonitor.getInstance(); @@ -157,7 +162,7 @@ export class BuilderContext { const batch = executableNodes.slice(i, i + concurrencyLimit); try { - const nodeExecutionPromises = batch.map(async (node) => { + batch.map(async (node) => { if (this.executionState.completed.has(node.id)) { return; } @@ -169,6 +174,7 @@ export class BuilderContext { currentStep.id, ); + let res; try { if (!this.canExecute(node.id)) { this.logger.log( @@ -185,7 +191,8 @@ export class BuilderContext { } this.logger.log(`Executing node ${node.id} in parallel batch`); - await this.executeNodeById(node.id); + res = this.executeNodeById(node.id); + this.globalPromises.add(res); this.monitor.endNodeExecution( node.id, @@ -205,8 +212,7 @@ export class BuilderContext { } }); - await Promise.all(nodeExecutionPromises); - + await Promise.all(this.globalPromises); const activeModelPromises = this.model.getAllActivePromises(); if (activeModelPromises.length > 0) { this.logger.debug( @@ -336,7 +342,7 @@ export class BuilderContext { this.executionState.completed.has(nodeId) || this.executionState.pending.has(nodeId) ) { - this.logger.debug(`Node ${nodeId} is already completed or pending.`); + //this.logger.debug(`Node ${nodeId} is already completed or pending.`); return false; } @@ -361,6 +367,7 @@ export class BuilderContext { this.executionState.pending.add(nodeId); const result = await this.invokeNodeHandler(node); this.executionState.completed.add(nodeId); + this.logger.log(`${nodeId} is completed`); this.executionState.pending.delete(nodeId); this.nodeData.set(node.id, result.data); @@ -438,10 +445,23 @@ export class BuilderContext { private async invokeNodeHandler(node: BuildNode): Promise> { const handler = this.handlerManager.getHandler(node.id); + this.logger.log(`sovling ${node.id}`); if (!handler) { throw new Error(`No handler found for node: ${node.id}`); } - - return handler.run(this, node.options); + try { + return await handler.run(this, node.options); + } catch (e) { + this.logger.error(`retrying ${node.id}`); + const result = await this.retryHandler.retryMethod( + e, + (node) => this.invokeNodeHandler(node), + [node], + ); + if (result === undefined) { + throw e; + } + return result as unknown as BuildResult; + } } } diff --git a/backend/src/build-system/errors.ts b/backend/src/build-system/errors.ts new file mode 100644 index 0000000..78a5834 --- /dev/null +++ b/backend/src/build-system/errors.ts @@ -0,0 +1,149 @@ +// error.ts + +/** + * Base class representing retryable errors. + * Inherits from JavaScript's built-in Error class. + * Suitable for errors where the system can attempt to retry the operation. + */ +export class RetryableError extends Error { + constructor(message: string) { + super(message); + this.name = 'RetryableError'; + Object.setPrototypeOf(this, new.target.prototype); // Fixes the inheritance chain for instanceof checks + } +} + +/** + * Base class representing non-retryable errors. + * Inherits from JavaScript's built-in Error class. + * Suitable for errors where the system should not attempt to retry the operation. + */ +export class NonRetryableError extends Error { + constructor(message: string) { + super(message); + this.name = 'NonRetryableError'; + Object.setPrototypeOf(this, new.target.prototype); // Fixes the inheritance chain for instanceof checks + } +} + +// Below are specific error classes inheriting from the appropriate base classes + +/** + * Error: File Not Found. + * Indicates that a required file could not be found during file operations. + * Non-retryable error. + */ +export class FileNotFoundError extends NonRetryableError { + constructor(message: string) { + super(message); + this.name = 'FileNotFoundError'; + } +} + +/** + * Error: File Modification Failed. + * Indicates issues encountered while modifying a file, such as insufficient permissions or disk errors. + * Non-retryable error. + */ +export class FileModificationError extends NonRetryableError { + constructor(message: string) { + super(message); + this.name = 'FileModificationError'; + } +} + +/** + * Error: Model Service Unavailable. + * Indicates that the underlying model service cannot be reached or is down. + * Retryable error, typically temporary. + */ +export class ModelUnavailableError extends RetryableError { + constructor(message: string) { + super(message); + this.name = 'ModelUnavailableError'; + } +} + +/** + * Error: Response Parsing Failed. + * Indicates that the system could not properly parse the response data. + * Retryable error, possibly due to temporary data format issues. + */ +export class ResponseParsingError extends RetryableError { + constructor(message: string) { + super(message); + this.name = 'ResponseParsingError'; + } +} + +/** + * Error: Response Tag Error. + * Indicates that expected tags in the response are missing or invalid during content generation or parsing. + * Non-retryable error. + */ +export class ResponseTagError extends NonRetryableError { + constructor(message: string) { + super(message); + this.name = 'ResponseTagError'; + } +} + +/** + * Error: Temporary Service Unavailable. + * Indicates that the service is unavailable due to temporary issues like server overload or maintenance. + * Retryable error, typically temporary. + */ +export class TemporaryServiceUnavailableError extends RetryableError { + constructor(message: string) { + super(message); + this.name = 'TemporaryServiceUnavailableError'; + } +} + +/** + * Error: Rate Limit Exceeded. + * Indicates that too many requests have been sent within a given time frame. + * Retryable error, may require waiting before retrying. + */ +export class RateLimitExceededError extends RetryableError { + constructor(message: string) { + super(message); + this.name = 'RateLimitExceededError'; + } +} + +/** + * Error: Missing Configuration. + * Indicates issues with system setup or missing configuration parameters. + * Non-retryable error, typically requires manual configuration fixes. + */ +export class MissingConfigurationError extends NonRetryableError { + constructor(message: string) { + super(message); + this.name = 'MissingConfigurationError'; + } +} + +/** + * Error: Invalid Parameter. + * Indicates that a function argument or configuration parameter is invalid. + * Non-retryable error, typically requires correcting the input parameters. + */ +export class InvalidParameterError extends NonRetryableError { + constructor(message: string) { + super(message); + this.name = 'InvalidParameterError'; + } +} + +/** + * Error: File Write Failed. + * Indicates issues encountered while writing to a file, such as insufficient permissions or disk errors. + * Non-retryable error. + */ +export class FileWriteError extends NonRetryableError { + constructor(message: string) { + super(message); + this.name = 'FileWriteError'; + } +} diff --git a/backend/src/build-system/handlers/backend/code-generate/index.ts b/backend/src/build-system/handlers/backend/code-generate/index.ts index 16dec84..ab90916 100644 --- a/backend/src/build-system/handlers/backend/code-generate/index.ts +++ b/backend/src/build-system/handlers/backend/code-generate/index.ts @@ -1,14 +1,15 @@ import { BuildHandler, BuildResult } from 'src/build-system/types'; import { BuilderContext } from 'src/build-system/context'; import { generateBackendCodePrompt } from './prompt'; -import { Logger } from '@nestjs/common'; import { saveGeneratedCode } from 'src/build-system/utils/files'; import * as path from 'path'; +import { formatResponse } from 'src/build-system/utils/strings'; import { - formatResponse, - parseGenerateTag, - removeCodeBlockFences, -} from 'src/build-system/utils/strings'; + FileWriteError, + InvalidParameterError, + MissingConfigurationError, + ResponseParsingError, +} from 'src/build-system/errors'; /** * BackendCodeHandler is responsible for generating the backend codebase @@ -16,31 +17,38 @@ import { */ export class BackendCodeHandler implements BuildHandler { readonly id = 'op:BACKEND:CODE'; - readonly logger: Logger = new Logger('BackendCodeHandler'); /** * Executes the handler to generate backend code. * @param context - The builder context containing configuration and utilities. - * @param args - The variadic arguments required for generating the backend code. * @returns A BuildResult containing the generated code and related data. */ async run(context: BuilderContext): Promise> { - this.logger.log('Generating Backend Codebase...'); - // Retrieve project name and database type from context const projectName = context.getGlobalContext('projectName') || 'Default Project Name'; const databaseType = context.getGlobalContext('databaseType') || 'Default database type'; - // Destructure arguments with default values for optional parameters + // Retrieve required documents const sitemapDoc = context.getNodeData('op:UX:SMD'); const datamapDoc = context.getNodeData('op:UX:DATAMAP:DOC'); const databaseSchemas = context.getNodeData('op:DATABASE:SCHEMAS'); - //TODO: make this backend generate similar as FileGenerateHandler, do file arch, and then generate each backend code - //TODO: backend requirement const backendRequirementDoc = - context.getNodeData('op:BACKEND:REQ').overview; + context.getNodeData('op:BACKEND:REQ')?.overview || ''; + + // Validate required data + if (!sitemapDoc || !datamapDoc || !databaseSchemas) { + throw new MissingConfigurationError( + 'Missing required configuration: sitemapDoc, datamapDoc, or databaseSchemas.', + ); + } + + if (typeof databaseSchemas !== 'object') { + throw new InvalidParameterError( + 'databaseSchemas should be a valid object.', + ); + } const currentFile = 'index.js'; const dependencyFile = 'dependencies.json'; @@ -58,34 +66,41 @@ export class BackendCodeHandler implements BuildHandler { dependencyFile, ); - // Log the prompt generation - this.logger.debug('Generated backend code prompt.'); + const modelResponse = await context.model.chatSync({ + model: 'gpt-4o-mini', + messages: [{ content: backendCodePrompt, role: 'system' }], + }); + let generatedCode: string; try { - // Invoke the language model to generate the backend code - const modelResponse = await context.model.chatSync({ - model: 'gpt-4o-mini', - messages: [{ content: backendCodePrompt, role: 'system' }], - }); - - const generatedCode = formatResponse(modelResponse); - - const uuid = context.getGlobalContext('projectUUID'); - saveGeneratedCode(path.join(uuid, 'backend', currentFile), generatedCode); + generatedCode = formatResponse(modelResponse); + if (!generatedCode) { + throw new ResponseParsingError('Response tag extraction failed.'); + } + } catch (error) { + if (error instanceof ResponseParsingError) { + throw error; + } + throw new ResponseParsingError( + 'Error occurred while parsing the model response.', + ); + } - this.logger.debug('Backend code generated and parsed successfully.'); + // Save the generated code to the specified location + const uuid = context.getGlobalContext('projectUUID'); + const savePath = path.join(uuid, 'backend', currentFile); - // TODO: return backend api as output - return { - success: true, - data: generatedCode, - }; + try { + saveGeneratedCode(savePath, generatedCode); } catch (error) { - this.logger.error('Error during backend code generation:', error); - return { - success: false, - error: new Error('Failed to generate backend code.'), - }; + throw new FileWriteError( + `Failed to save backend code to ${savePath}: ${error.message}`, + ); } + + return { + success: true, + data: generatedCode, + }; } } diff --git a/backend/src/build-system/handlers/backend/file-review/file-review.ts b/backend/src/build-system/handlers/backend/file-review/file-review.ts index 609b057..7ab09b2 100644 --- a/backend/src/build-system/handlers/backend/file-review/file-review.ts +++ b/backend/src/build-system/handlers/backend/file-review/file-review.ts @@ -6,12 +6,18 @@ import * as path from 'path'; import { prompts } from './prompt'; import { formatResponse } from 'src/build-system/utils/strings'; +import { + FileNotFoundError, + FileModificationError, + ResponseParsingError, + ModelTimeoutError, + TemporaryServiceUnavailableError, + RateLimitExceededError, +} from 'src/build-system/errors'; -// TODO(Sma1lboy): we need a better way to handle handler pre requirements /** - * - * Responsible for review all relate src root file and consider to modify them - * such as package.json, tsconfig.json, .env, etc. in js/ts project + * Responsible for reviewing all related source root files and considering modifications + * such as package.json, tsconfig.json, .env, etc., in JS/TS projects. * @requires [op:BACKEND:REQ] - BackendRequirementHandler */ export class BackendFileReviewHandler implements BuildHandler { @@ -21,90 +27,113 @@ export class BackendFileReviewHandler implements BuildHandler { async run(context: BuilderContext): Promise> { this.logger.log('Starting backend file modification process...'); - try { - const backendPath = - context.getGlobalContext('backendPath') || './backend'; - const projectName = - context.getGlobalContext('projectName') || 'Default Project Name'; - const description = - context.getGlobalContext('description') || - 'Default Project Description'; - const projectOverview = ` + const backendPath = context.getGlobalContext('backendPath') || './backend'; + const projectName = + context.getGlobalContext('projectName') || 'Default Project Name'; + const description = + context.getGlobalContext('description') || 'Default Project Description'; + const projectOverview = ` project name: ${projectName} project description: ${description}, `; - const backendRequirement = context.getNodeData('op:BACKEND:REQ').overview; - const backendCode = [context.getNodeData('op:BACKEND:CODE')]; // Convert to array for now - // 1. Identify files to modify + const backendRequirement = context.getNodeData('op:BACKEND:REQ')?.overview; + const backendCode = [context.getNodeData('op:BACKEND:CODE')]; + + if (!backendRequirement) { + throw new FileNotFoundError('Backend requirements are missing.'); + } + + let files: string[]; + try { this.logger.log(`Scanning backend directory: ${backendPath}`); - const files = await fs.readdir(backendPath); + files = await fs.readdir(backendPath); + if (!files.length) { + throw new FileNotFoundError('No files found in the backend directory.'); + } this.logger.debug(`Found files: ${files.join(', ')}`); + } catch (error) { + throw error; + } - const filePrompt = prompts.identifyBackendFilesToModify( - files, - backendRequirement, - projectOverview, - backendCode, - ); + const filePrompt = prompts.identifyBackendFilesToModify( + files, + backendRequirement, + projectOverview, + backendCode, + ); - const modelResponse = await context.model.chatSync({ + let modelResponse: string; + try { + modelResponse = await context.model.chatSync({ model: 'gpt-4o-mini', messages: [{ content: filePrompt, role: 'system' }], }); + } catch (error) { + throw error; + } - const filesToModify = this.parseFileIdentificationResponse(modelResponse); - this.logger.log(`Files to modify: ${filesToModify.join(', ')}`); - - // 2. Modify each identified file - for (const fileName of filesToModify) { - const filePath = path.join(backendPath, fileName); - try { - // Read current content - const currentContent = await fs.readFile(filePath, 'utf-8'); - - // Generate modification prompt - const modificationPrompt = prompts.generateFileModificationPrompt( - fileName, - currentContent, - backendRequirement, - projectOverview, - backendCode, - ); + const filesToModify = this.parseFileIdentificationResponse(modelResponse); + if (!filesToModify.length) { + throw new FileModificationError('No files identified for modification.'); + } + this.logger.log(`Files to modify: ${filesToModify.join(', ')}`); - // Get modified content - const response = await context.model.chatSync({ - model: 'gpt-4o-mini', - messages: [{ content: modificationPrompt, role: 'system' }], - }); + for (const fileName of filesToModify) { + const filePath = path.join(backendPath, fileName); + try { + const currentContent = await fs.readFile(filePath, 'utf-8'); + const modificationPrompt = prompts.generateFileModificationPrompt( + fileName, + currentContent, + backendRequirement, + projectOverview, + backendCode, + ); - // Extract new content and write back - const newContent = formatResponse(response); - await fs.writeFile(filePath, newContent, 'utf-8'); + const response = await context.model.chatSync({ + model: 'gpt-4o-mini', + messages: [{ content: modificationPrompt, role: 'system' }], + }); - this.logger.log(`Successfully modified ${fileName}`); - } catch (error) { - this.logger.error(`Error modifying file ${fileName}:`, error); - throw error; + const newContent = formatResponse(response); + if (!newContent) { + throw new FileModificationError( + `Failed to generate content for file: ${fileName}.`, + ); } - } - return { - success: true, - data: `Modified files: ${filesToModify.join(', ')}`, - }; - } catch (error) { - this.logger.error('Error during backend file modification:', error); - return { - success: false, - error: new Error('Failed to modify backend files.'), - }; + await fs.writeFile(filePath, newContent, 'utf-8'); + this.logger.log(`Successfully modified ${fileName}`); + } catch (error) { + throw error; + } } + + return { + success: true, + data: `Modified files: ${filesToModify.join(', ')}`, + }; } + /** + * Parses the file identification response from the model. + */ parseFileIdentificationResponse(response: string): string[] { - const parsedResponse = JSON.parse(formatResponse(response)); - this.logger.log('Parsed file identification response:', parsedResponse); - return parsedResponse; + try { + const parsedResponse = JSON.parse(formatResponse(response)); + if (!Array.isArray(parsedResponse)) { + throw new ResponseParsingError( + 'File identification response is not an array.', + ); + } + this.logger.log('Parsed file identification response:', parsedResponse); + return parsedResponse; + } catch (error) { + this.logger.error('Error parsing file identification response:', error); + throw new ResponseParsingError( + 'Failed to parse file identification response.', + ); + } } } diff --git a/backend/src/build-system/handlers/backend/requirements-document/index.ts b/backend/src/build-system/handlers/backend/requirements-document/index.ts index c5eb486..d5c7687 100644 --- a/backend/src/build-system/handlers/backend/requirements-document/index.ts +++ b/backend/src/build-system/handlers/backend/requirements-document/index.ts @@ -1,11 +1,16 @@ import { BuildHandler, BuildResult } from 'src/build-system/types'; import { BuilderContext } from 'src/build-system/context'; -import { - generateBackendImplementationPrompt, - generateBackendOverviewPrompt, -} from './prompt'; +import { generateBackendOverviewPrompt } from './prompt'; import { Logger } from '@nestjs/common'; import { removeCodeBlockFences } from 'src/build-system/utils/strings'; +import { + ResponseParsingError, + MissingConfigurationError, + ModelTimeoutError, + TemporaryServiceUnavailableError, + RateLimitExceededError, + ModelUnavailableError, +} from 'src/build-system/errors'; type BackendRequirementResult = { overview: string; @@ -18,14 +23,14 @@ type BackendRequirementResult = { }; /** - * BackendRequirementHandler is responsible for generating the backend requirements document + * BackendRequirementHandler is responsible for generating the backend requirements document. * Core Content Generation: API Endpoints, System Overview */ export class BackendRequirementHandler implements BuildHandler { readonly id = 'op:BACKEND:REQ'; - readonly logger: Logger = new Logger('BackendRequirementHandler'); + private readonly logger: Logger = new Logger('BackendRequirementHandler'); async run( context: BuilderContext, @@ -42,6 +47,15 @@ export class BackendRequirementHandler const datamapDoc = context.getNodeData('op:UX:DATAMAP:DOC'); const sitemapDoc = context.getNodeData('op:UX:SMD'); + if (!dbRequirements || !datamapDoc || !sitemapDoc) { + this.logger.error( + 'Missing required parameters: dbRequirements, datamapDoc, or sitemapDoc', + ); + throw new MissingConfigurationError( + 'Missing required parameters: dbRequirements, datamapDoc, or sitemapDoc.', + ); + } + const overviewPrompt = generateBackendOverviewPrompt( projectName, dbRequirements, @@ -58,47 +72,25 @@ export class BackendRequirementHandler model: 'gpt-4o-mini', messages: [{ content: overviewPrompt, role: 'system' }], }); + + if (!backendOverview) { + throw new ModelTimeoutError( + 'The model did not respond within the expected time.', + ); + } + if (backendOverview.trim() === '') { + throw new ResponseParsingError('Generated backend overview is empty.'); + } } catch (error) { - this.logger.error('Error generating backend overview:', error); - return { - success: false, - error: new Error('Failed to generate backend overview.'), - }; + throw error; } - // // Generate backend implementation details - // const implementationPrompt = generateBackendImplementationPrompt( - // backendOverview, - // language, - // framework, - // ); - - // let implementationDetails: string; - // try { - // implementationDetails = await context.model.chatSync( - // { - // content: implementationPrompt, - // }, - // 'gpt-4o-mini', - // ); - // } catch (error) { - // this.logger.error( - // 'Error generating backend implementation details:', - // error, - // ); - // return { - // success: false, - // error: new Error('Failed to generate backend implementation details.'), - // }; - // } - // Return generated data return { success: true, data: { overview: removeCodeBlockFences(backendOverview), - // TODO: consider remove implementation - implementation: '', + implementation: '', // Implementation generation skipped config: { language, framework, diff --git a/backend/src/build-system/handlers/database/requirements-document/index.ts b/backend/src/build-system/handlers/database/requirements-document/index.ts index 4cb0dff..a80abd7 100644 --- a/backend/src/build-system/handlers/database/requirements-document/index.ts +++ b/backend/src/build-system/handlers/database/requirements-document/index.ts @@ -4,26 +4,65 @@ import { ModelProvider } from 'src/common/model-provider'; import { prompts } from './prompt'; import { Logger } from '@nestjs/common'; import { removeCodeBlockFences } from 'src/build-system/utils/strings'; +import { + MissingConfigurationError, + ResponseParsingError, + ModelTimeoutError, + TemporaryServiceUnavailableError, + RateLimitExceededError, +} from 'src/build-system/errors'; export class DatabaseRequirementHandler implements BuildHandler { readonly id = 'op:DATABASE_REQ'; - readonly logger = new Logger('DatabaseRequirementHandler'); + private readonly logger = new Logger('DatabaseRequirementHandler'); + async run(context: BuilderContext): Promise> { + const model = ModelProvider.getInstance(); this.logger.log('Generating Database Requirements Document...'); const projectName = context.getGlobalContext('projectName') || 'Default Project Name'; const datamapDoc = context.getNodeData('op:UX:DATAMAP:DOC'); + if (!datamapDoc) { + this.logger.error('Data mapping document is missing.'); + throw new MissingConfigurationError( + 'Missing required parameter: datamapDoc.', + ); + } + const prompt = prompts.generateDatabaseRequirementPrompt( projectName, datamapDoc, ); - const model = ModelProvider.getInstance(); - const dbRequirementsContent = await model.chatSync({ - model: 'gpt-4o-mini', - messages: [{ content: prompt, role: 'system' }], - }); + + let dbRequirementsContent: string; + + try { + dbRequirementsContent = await model.chatSync({ + model: 'gpt-4o-mini', + messages: [{ content: prompt, role: 'system' }], + }); + + if (!dbRequirementsContent) { + throw new ModelTimeoutError( + 'The model did not respond within the expected time.', + ); + } + + if (dbRequirementsContent.trim() === '') { + throw new ResponseParsingError( + 'Generated database requirements content is empty.', + ); + } + } catch (error) { + this.logger.error( + 'Error during database requirements generation:', + error, + ); + throw error; // Propagate error to upper-level handler + } + return { success: true, data: removeCodeBlockFences(dbRequirementsContent), diff --git a/backend/src/build-system/handlers/database/schemas/schemas.ts b/backend/src/build-system/handlers/database/schemas/schemas.ts index 9acd678..d924849 100644 --- a/backend/src/build-system/handlers/database/schemas/schemas.ts +++ b/backend/src/build-system/handlers/database/schemas/schemas.ts @@ -7,11 +7,18 @@ import { getSupportedDatabaseTypes, isSupportedDatabaseType, } from '../../../utils/database-utils'; -import { writeFile } from 'fs-extra'; import { prompts } from './prompt'; import { saveGeneratedCode } from 'src/build-system/utils/files'; import * as path from 'path'; import { formatResponse } from 'src/build-system/utils/strings'; +import { + MissingConfigurationError, + ResponseParsingError, + FileWriteError, + ModelTimeoutError, + TemporaryServiceUnavailableError, + RateLimitExceededError, +} from 'src/build-system/errors'; /** * DBSchemaHandler is responsible for generating database schemas based on provided requirements. @@ -20,160 +27,163 @@ export class DBSchemaHandler implements BuildHandler { readonly id = 'op:DATABASE:SCHEMAS'; private readonly logger: Logger = new Logger('DBSchemaHandler'); - /** - * Executes the handler to generate database schemas. - * @param context - The builder context containing configuration and utilities. - * @param args - The variadic arguments required for generating the database schemas. - * @returns A BuildResult containing the generated schema content and related data. - */ async run(context: BuilderContext): Promise { this.logger.log('Generating Database Schemas...'); - // Retrieve projectName and databaseType from context const projectName = context.getGlobalContext('projectName') || 'Default Project Name'; const databaseType = context.getGlobalContext('databaseType') || 'PostgreSQL'; const dbRequirements = context.getNodeData('op:DATABASE_REQ'); + if (!dbRequirements) { + this.logger.error('Missing database requirements.'); + throw new MissingConfigurationError( + 'Missing required database requirements.', + ); + } - this.logger.debug('Database requirements are provided.'); - - // Check if the databaseType is supported if (!isSupportedDatabaseType(databaseType)) { - throw new Error( - `Unsupported database type: ${databaseType}. Supported types are: ${getSupportedDatabaseTypes().join( - ', ', - )}.`, + const supportedTypes = getSupportedDatabaseTypes().join(', '); + this.logger.error( + `Unsupported database type: ${databaseType}. Supported types: ${supportedTypes}`, + ); + throw new MissingConfigurationError( + `Unsupported database type: ${databaseType}. Supported types: ${supportedTypes}.`, ); } - // Get the file extension for the schema let fileExtension: string; try { fileExtension = getSchemaFileExtension(databaseType as DatabaseType); } catch (error) { this.logger.error('Error determining schema file extension:', error); - throw new Error( + throw new ResponseParsingError( `Failed to determine schema file extension for database type: ${databaseType}.`, ); } this.logger.debug(`Schema file extension: .${fileExtension}`); - // Step 1: Analyze database requirements + const dbAnalysis = await this.analyzeDatabaseRequirements( + context, + projectName, + dbRequirements, + databaseType, + ); + + const schemaContent = await this.generateDatabaseSchema( + context, + dbAnalysis, + databaseType, + fileExtension, + ); + + await this.validateDatabaseSchema(context, schemaContent, databaseType); + + const schemaFileName = `schema.${fileExtension}`; + const uuid = context.getGlobalContext('projectUUID'); + + try { + saveGeneratedCode( + path.join(uuid, 'backend', schemaFileName), + schemaContent, + ); + this.logger.log(`Schema file (${schemaFileName}) written successfully.`); + } catch (error) { + this.logger.error('Error writing schema file:', error); + throw new FileWriteError('Failed to write schema file.'); + } + + return { + success: true, + data: schemaContent, + }; + } + + private async analyzeDatabaseRequirements( + context: BuilderContext, + projectName: string, + dbRequirements: any, + databaseType: string, + ): Promise { const analysisPrompt = prompts.analyzeDatabaseRequirements( projectName, dbRequirements, databaseType, ); - let dbAnalysis: string; try { const analysisResponse = await context.model.chatSync({ model: 'gpt-4o-mini', messages: [{ content: analysisPrompt, role: 'system' }], }); - dbAnalysis = analysisResponse; - } catch (error) { - this.logger.error('Error during database requirements analysis:', error); - return { - success: false, - error: new Error('Failed to analyze database requirements.'), - }; - } - this.logger.debug('Database requirements analyzed successfully.'); + if (!analysisResponse || analysisResponse.trim() === '') { + throw new ResponseParsingError( + 'Database requirements analysis returned empty.', + ); + } - // Step 2: Generate database schema based on analysis - let schemaPrompt: string; - try { - schemaPrompt = prompts.generateDatabaseSchema( - dbAnalysis, - databaseType, - fileExtension, - ); + return analysisResponse; } catch (error) { - this.logger.error('Error during schema prompt generation:', error); - return { - success: false, - error: new Error('Failed to generate schema prompt.'), - }; + throw error; } + } + + private async generateDatabaseSchema( + context: BuilderContext, + dbAnalysis: string, + databaseType: string, + fileExtension: string, + ): Promise { + const schemaPrompt = prompts.generateDatabaseSchema( + dbAnalysis, + databaseType, + fileExtension, + ); - let schemaContent: string; try { const schemaResponse = await context.model.chatSync({ model: 'gpt-4o-mini', messages: [{ content: schemaPrompt, role: 'system' }], }); - schemaContent = formatResponse(schemaResponse); + + const schemaContent = formatResponse(schemaResponse); + if (!schemaContent || schemaContent.trim() === '') { + throw new ResponseParsingError('Generated database schema is empty.'); + } + + return schemaContent; } catch (error) { - this.logger.error('Error during schema generation:', error); - return { - success: false, - error: new Error('Failed to generate database schema.'), - }; + throw error; } + } - this.logger.debug('Database schema generated successfully.'); - - // Step 3: Validate the generated schema + private async validateDatabaseSchema( + context: BuilderContext, + schemaContent: string, + databaseType: string, + ): Promise { const validationPrompt = prompts.validateDatabaseSchema( schemaContent, databaseType, ); - let validationResponse: string; try { const validationResult = await context.model.chatSync({ model: 'gpt-4o-mini', messages: [{ content: validationPrompt, role: 'system' }], }); - validationResponse = formatResponse(validationResult); - } catch (error) { - this.logger.error('Error during schema validation:', error); - return { - success: false, - error: new Error('Failed to validate the generated database schema.'), - }; - } - if (validationResponse.includes('Error')) { - this.logger.error('Schema validation failed:', validationResponse); - return { - success: false, - error: new Error(`Schema validation failed: ${validationResponse}`), - }; - } - - this.logger.debug('Schema validation passed.'); - - // Define the schema file name - const schemaFileName = `schema.${fileExtension}`; - - // Write the schemaContent to a file - const uuid = context.getGlobalContext('projectUUID'); - - try { - saveGeneratedCode( - path.join(uuid, 'backend', schemaFileName), - schemaContent, - ); - this.logger.log(`Schema file (${schemaFileName}) written successfully.`); + const validationResponse = formatResponse(validationResult); + if (validationResponse.includes('Error')) { + throw new ResponseParsingError( + `Schema validation failed: ${validationResponse}`, + ); + } } catch (error) { - this.logger.error('Error writing schema file:', error); - return { - success: false, - error: new Error('Failed to write schema file.'), - }; + throw error; } - - this.logger.debug(`Schema file (${schemaFileName}) prepared.`); - - return { - success: true, - data: schemaContent, - }; } } diff --git a/backend/src/build-system/handlers/file-manager/file-arch/index.ts b/backend/src/build-system/handlers/file-manager/file-arch/index.ts index 1b82c76..a8af604 100644 --- a/backend/src/build-system/handlers/file-manager/file-arch/index.ts +++ b/backend/src/build-system/handlers/file-manager/file-arch/index.ts @@ -7,27 +7,28 @@ import { formatResponse, parseGenerateTag, } from 'src/build-system/utils/strings'; +import { + ResponseParsingError, + InvalidParameterError, + ModelTimeoutError, + TemporaryServiceUnavailableError, + RateLimitExceededError, +} from 'src/build-system/errors'; export class FileArchGenerateHandler implements BuildHandler { readonly id = 'op:FILE:ARCH'; private readonly logger: Logger = new Logger('FileArchGenerateHandler'); - // TODO: adding page by page analysis async run(context: BuilderContext): Promise> { this.logger.log('Generating File Architecture Document...'); const fileStructure = context.getNodeData('op:FILE:STRUCT'); - // TODO: here should use datamap struct const datamapDoc = context.getNodeData('op:UX:DATAMAP:DOC'); - // TODO: adding page by page analysis if (!fileStructure || !datamapDoc) { - return { - success: false, - error: new Error( - 'Missing required parameters: fileStructure or datamapDoc', - ), - }; + throw new InvalidParameterError( + 'Missing required parameters: fileStructure or datamapDoc.', + ); } const prompt = generateFileArchPrompt( @@ -35,67 +36,45 @@ export class FileArchGenerateHandler implements BuildHandler { JSON.stringify(datamapDoc, null, 2), ); - // fileArchContent generate - let successBuild = false; - let fileArchContent = null; - let jsonData = null; - let retry = 0; - const retryChances = 2; + try { + const fileArchContent = await context.model.chatSync({ + model: 'gpt-4o-mini', + messages: [{ content: prompt, role: 'system' }], + }); - // TODO: not ideal, should implement a better global retry mechanism - while (!successBuild) { - if (retry > retryChances) { - this.logger.error( - 'Failed to build virtual directory after multiple attempts', + if (!fileArchContent) { + throw new ModelTimeoutError( + 'The model did not respond within the expected time.', ); - return { - success: false, - error: new Error( - 'Failed to build virtual directory after multiple attempts', - ), - }; } - try { - fileArchContent = await context.model.chatSync({ - model: 'gpt-4o-mini', - messages: [{ content: prompt, role: 'system' }], - }); - const tagContent = parseGenerateTag(fileArchContent); - jsonData = extractJsonFromText(tagContent); + const tagContent = parseGenerateTag(fileArchContent); + const jsonData = extractJsonFromText(tagContent); - if (jsonData == null) { - retry += 1; - this.logger.error('Extract Json From Text fail'); - continue; - } - - // Validate the extracted JSON data - if (!this.validateJsonData(jsonData)) { - retry += 1; - this.logger.error('File architecture JSON validation failed'); - continue; - } + if (!jsonData) { + this.logger.error('Failed to extract JSON from text.'); + throw new ResponseParsingError('Failed to extract JSON from text.'); + } - successBuild = true; - } catch (error) { - this.logger.error('Error during JSON extraction or validation', error); - return { - success: false, - error: new Error('Error during JSON extraction or validation'), - }; + if (!this.validateJsonData(jsonData)) { + this.logger.error('File architecture JSON validation failed.'); + throw new ResponseParsingError( + 'File architecture JSON validation failed.', + ); } - } - this.logger.log('File architecture document generated successfully'); - return { - success: true, - data: formatResponse(fileArchContent), - }; + this.logger.log('File architecture document generated successfully.'); + return { + success: true, + data: formatResponse(fileArchContent), + }; + } catch (error) { + throw error; + } } /** - * Validate the structure and content of the JSON data. + * Validates the structure and content of the JSON data. * @param jsonData The JSON data to validate. * @returns A boolean indicating whether the JSON data is valid. */ @@ -105,13 +84,11 @@ export class FileArchGenerateHandler implements BuildHandler { const validPathRegex = /^[a-zA-Z0-9_\-/.]+$/; for (const [file, details] of Object.entries(jsonData.files)) { - // Validate the file path if (!validPathRegex.test(file)) { this.logger.error(`Invalid file path: ${file}`); return false; } - // Validate dependencies for (const dependency of details.dependsOn) { if (!validPathRegex.test(dependency)) { this.logger.error( @@ -120,7 +97,6 @@ export class FileArchGenerateHandler implements BuildHandler { return false; } - // Ensure no double slashes or trailing slashes if (dependency.includes('//') || dependency.endsWith('/')) { this.logger.error( `Malformed dependency path "${dependency}" in file "${file}".`, diff --git a/backend/src/build-system/handlers/file-manager/file-generate/index.ts b/backend/src/build-system/handlers/file-manager/file-generate/index.ts index 29f261c..71fca2f 100644 --- a/backend/src/build-system/handlers/file-manager/file-generate/index.ts +++ b/backend/src/build-system/handlers/file-manager/file-generate/index.ts @@ -8,6 +8,11 @@ import { extractJsonFromMarkdown } from 'src/build-system/utils/strings'; import { getProjectPath } from 'src/config/common-path'; import normalizePath from 'normalize-path'; import toposort from 'toposort'; +import { + InvalidParameterError, + ResponseParsingError, + FileWriteError, +} from 'src/build-system/errors'; export class FileGeneratorHandler implements BuildHandler { readonly id = 'op:FILE:GENERATE'; @@ -19,59 +24,71 @@ export class FileGeneratorHandler implements BuildHandler { const fileArchDoc = context.getNodeData('op:FILE:ARCH'); const uuid = context.getGlobalContext('projectUUID'); + if (!fileArchDoc) { + this.logger.error('File architecture document is missing.'); + throw new InvalidParameterError( + 'Missing required parameter: fileArchDoc.', + ); + } + const projectSrcPath = getProjectPath(uuid); try { await this.generateFiles(fileArchDoc, projectSrcPath); - } catch (error) { - this.logger.error('Error during file generation process', error); + this.logger.log('All files generated successfully.'); return { - success: false, - error: new Error('Failed to generate files and dependencies.'), + success: true, + data: 'Files and dependencies created successfully.', }; + } catch (error) { + throw error; } - - return { - success: true, - data: 'Files and dependencies created successfully.', - }; } - /** - * Generate files based on the JSON extracted from a Markdown file. - * @param markdownContent The Markdown content containing the JSON. - * @param projectSrcPath The base directory where files should be generated. - */ async generateFiles( markdownContent: string, projectSrcPath: string, ): Promise { const jsonData = extractJsonFromMarkdown(markdownContent); - // Build the dependency graph and detect cycles before any file operations + if (!jsonData || !jsonData.files) { + this.logger.error('Invalid or empty file architecture data.'); + throw new ResponseParsingError( + 'Invalid or empty file architecture data.', + ); + } + const { graph, nodes } = this.buildDependencyGraph(jsonData); - this.detectCycles(graph); - // Validate files against the virtual directory structure - this.validateAgainstVirtualDirectory(nodes); + try { + this.detectCycles(graph); + } catch (error) { + this.logger.error('Circular dependency detected.', error); + throw new InvalidParameterError( + `Circular dependency detected: ${error.message}`, + ); + } + + try { + this.validateAgainstVirtualDirectory(nodes); + } catch (error) { + this.logger.error('Validation against virtual directory failed.', error); + throw new InvalidParameterError(error.message); + } - // Perform topological sort for file generation const sortedFiles = this.getSortedFiles(graph, nodes); - // Generate files in dependency order for (const file of sortedFiles) { const fullPath = normalizePath(path.resolve(projectSrcPath, file)); this.logger.log(`Generating file in dependency order: ${fullPath}`); - await this.createFile(fullPath); + try { + await this.createFile(fullPath); + } catch (error) { + throw new FileWriteError(`Failed to create file: ${file}`); + } } - - this.logger.log('All files generated successfully.'); } - /** - * Build dependency graph from JSON data. - * @param jsonData The JSON data containing file dependencies. - */ private buildDependencyGraph(jsonData: { files: Record; }): { graph: [string, string][]; nodes: Set } { @@ -82,7 +99,7 @@ export class FileGeneratorHandler implements BuildHandler { nodes.add(fileName); details.dependsOn.forEach((dep) => { const resolvedDep = this.resolveDependency(fileName, dep); - graph.push([resolvedDep, fileName]); // [dependency, dependent] + graph.push([resolvedDep, fileName]); nodes.add(resolvedDep); }); }); @@ -90,36 +107,25 @@ export class FileGeneratorHandler implements BuildHandler { return { graph, nodes }; } - /** - * Detect cycles in the dependency graph. - * @param graph The dependency graph to check. - * @throws Error if a cycle is detected. - */ private detectCycles(graph: [string, string][]): void { try { toposort(graph); } catch (error) { if (error.message.includes('cycle')) { - throw new Error( - `Circular dependency detected in the file structure: ${error.message}`, + throw new InvalidParameterError( + `Circular dependency detected: ${error.message}`, ); } throw error; } } - /** - * Get topologically sorted list of files. - * @param graph The dependency graph. - * @param nodes Set of all nodes. - */ private getSortedFiles( graph: [string, string][], nodes: Set, ): string[] { const sortedFiles = toposort(graph).reverse(); - // Add any files with no dependencies Array.from(nodes).forEach((node) => { if (!sortedFiles.includes(node)) { sortedFiles.unshift(node); @@ -129,11 +135,6 @@ export class FileGeneratorHandler implements BuildHandler { return sortedFiles; } - /** - * Resolve a dependency path relative to the current file. - * @param currentFile The current file's path. - * @param dependency The dependency path. - */ private resolveDependency(currentFile: string, dependency: string): string { const currentDir = path.dirname(currentFile); const hasExtension = path.extname(dependency).length > 0; @@ -147,11 +148,6 @@ export class FileGeneratorHandler implements BuildHandler { return resolvedPath; } - /** - * Validate all files and dependencies against the virtual directory. - * @param nodes Set of all files and dependencies. - * @throws Error if any file or dependency is not valid. - */ private validateAgainstVirtualDirectory(nodes: Set): void { const invalidFiles: string[] = []; @@ -162,26 +158,18 @@ export class FileGeneratorHandler implements BuildHandler { }); if (invalidFiles.length > 0) { - throw new Error( + throw new InvalidParameterError( `The following files do not exist in the project structure:\n${invalidFiles.join('\n')}`, ); } } - /** - * Create a file, including creating necessary directories. - * @param filePath The full path of the file to create. - */ private async createFile(filePath: string): Promise { const dir = path.dirname(filePath); - - // Ensure the directory exists await fs.mkdir(dir, { recursive: true }); - // Create the file with a placeholder content const content = `// Generated file: ${path.basename(filePath)}`; await fs.writeFile(filePath, content, 'utf8'); - this.logger.log(`File created: ${filePath}`); } } diff --git a/backend/src/build-system/handlers/file-manager/file-structure/index.ts b/backend/src/build-system/handlers/file-manager/file-structure/index.ts index d9989cd..7288c35 100644 --- a/backend/src/build-system/handlers/file-manager/file-structure/index.ts +++ b/backend/src/build-system/handlers/file-manager/file-structure/index.ts @@ -8,6 +8,10 @@ import { BuilderContext } from 'src/build-system/context'; import { prompts } from './prompt'; import { Logger } from '@nestjs/common'; import { removeCodeBlockFences } from 'src/build-system/utils/strings'; +import { + ResponseParsingError, + MissingConfigurationError, +} from 'src/build-system/errors'; /** * FileStructureHandler is responsible for generating the project's file and folder structure @@ -17,12 +21,6 @@ export class FileStructureHandler implements BuildHandler { readonly id = 'op:FILE:STRUCT'; private readonly logger: Logger = new Logger('FileStructureHandler'); - /** - * Executes the handler to generate the file structure. - * @param context - The builder context containing configuration and utilities. - * @param args - The variadic arguments required for generating the file structure. - * @returns A BuildResult containing the generated file structure JSON and related data. - */ async run( context: BuilderContext, opts?: BuildOpts, @@ -32,45 +30,13 @@ export class FileStructureHandler implements BuildHandler { // Retrieve projectName from context const projectName = context.getGlobalContext('projectName') || 'Default Project Name'; - this.logger.debug(`Project Name: ${projectName}`); - const sitemapDoc = context.getNodeData('op:UX:SMD'); const datamapDoc = context.getNodeData('op:UX:DATAMAP:DOC'); - // TODO: make sure passing this parameter is correct - const projectPart = opts.projectPart ?? 'frontend'; + const projectPart = opts?.projectPart ?? 'frontend'; const framework = context.getGlobalContext('framework') ?? 'react'; - this.logger.warn( - "there is no default framework setup, using 'react', plz fix it ASAP", - ); - // Validate required arguments - if (!sitemapDoc || typeof sitemapDoc !== 'string') { - throw new Error( - 'The first argument (sitemapDoc) is required and must be a string.', - ); - } - if (!datamapDoc || typeof datamapDoc !== 'string') { - throw new Error( - 'The second argument (datamapDoc) is required and must be a string.', - ); - } - if (!framework || typeof framework !== 'string') { - throw new Error( - 'The third argument (framework) is required and must be a string.', - ); - } - if ( - !projectPart || - (projectPart !== 'frontend' && projectPart !== 'backend') - ) { - throw new Error( - 'The fourth argument (projectPart) is required and must be either "frontend" or "backend".', - ); - } - this.logger.debug(`Project Part: ${projectPart}`); - this.logger.debug( - 'Sitemap Documentation and Data Analysis Document are provided.', - ); + // Validate required arguments + this.validateInputs(sitemapDoc, datamapDoc, framework, projectPart); // Generate the common file structure prompt const prompt = prompts.generateCommonFileStructurePrompt( @@ -83,73 +49,57 @@ export class FileStructureHandler implements BuildHandler { let fileStructureContent: string; try { - // Invoke the language model to generate the file structure content fileStructureContent = await context.model.chatSync({ model: 'gpt-4o-mini', messages: [{ content: prompt, role: 'system' }], }); + + if (!fileStructureContent || fileStructureContent.trim() === '') { + throw new ResponseParsingError( + `Generated content is empty during op:FILE:STRUCT.`, + ); + } } catch (error) { - this.logger.error('Error during file structure generation:', error); - return { - success: false, - error: new Error('Failed to generate file structure.'), - }; + return { success: false, error }; } - this.logger.debug('Generated file structure content.'); - - // Convert the tree structure to JSON using the appropriate prompt + // Convert the tree structure to JSON const convertToJsonPrompt = prompts.convertTreeToJsonPrompt(fileStructureContent); - let fileStructureJsonContent: string | null = null; - let successBuild = false; - let retry = 0; - const retryChances = 2; + let fileStructureJsonContent: string; + try { + fileStructureJsonContent = await context.model.chatSync({ + model: 'gpt-4o-mini', + messages: [{ content: convertToJsonPrompt, role: 'system' }], + }); - while (!successBuild) { - if (retry > retryChances) { - this.logger.error( - 'Failed to build virtual directory after multiple attempts.', + if (!fileStructureJsonContent || fileStructureJsonContent.trim() === '') { + throw new ResponseParsingError( + `Generated content is empty during op:FILE:STRUCT 2.`, ); - return { - success: false, - error: new Error( - 'Failed to build virtual directory after multiple attempts.', - ), - }; - } - - try { - // Invoke the language model to convert tree structure to JSON - fileStructureJsonContent = await context.model.chatSync({ - model: 'gpt-4o-mini', - messages: [{ content: convertToJsonPrompt, role: 'system' }], - }); - } catch (error) { - this.logger.error('Error during tree to JSON conversion:', error); - return { - success: false, - error: new Error('Failed to convert file structure to JSON.'), - }; - } - - this.logger.debug('Converted file structure to JSON.'); - - // Attempt to build the virtual directory from the JSON structure - try { - successBuild = context.buildVirtualDirectory(fileStructureJsonContent); - } catch (error) { - this.logger.error('Error during virtual directory build:', error); - successBuild = false; } + } catch (error) { + return { success: false, error }; + } + // Build the virtual directory + try { + const successBuild = context.buildVirtualDirectory( + fileStructureJsonContent, + ); if (!successBuild) { - this.logger.warn( - `Retrying to build virtual directory (${retry + 1}/${retryChances})...`, - ); - retry += 1; + throw new ResponseParsingError('Failed to build virtual directory.'); } + } catch (error) { + this.logger.error( + 'Non-retryable error during virtual directory build:', + error, + ); + return { + success: false, + error: new ResponseParsingError('Failed to build virtual directory.'), + }; } this.logger.log( @@ -164,4 +114,26 @@ export class FileStructureHandler implements BuildHandler { }, }; } + + private validateInputs( + sitemapDoc: any, + datamapDoc: any, + framework: string, + projectPart: string, + ): void { + if (!sitemapDoc || typeof sitemapDoc !== 'string') { + throw new MissingConfigurationError('Missing or invalid sitemapDoc.'); + } + if (!datamapDoc || typeof datamapDoc !== 'string') { + throw new MissingConfigurationError('Missing or invalid datamapDoc.'); + } + if (!framework || typeof framework !== 'string') { + throw new MissingConfigurationError('Missing or invalid framework.'); + } + if (!['frontend', 'backend'].includes(projectPart)) { + throw new MissingConfigurationError( + 'Invalid projectPart. Must be either "frontend" or "backend".', + ); + } + } } diff --git a/backend/src/build-system/handlers/product-manager/product-requirements-document/prd.ts b/backend/src/build-system/handlers/product-manager/product-requirements-document/prd.ts index 1301870..1f23232 100644 --- a/backend/src/build-system/handlers/product-manager/product-requirements-document/prd.ts +++ b/backend/src/build-system/handlers/product-manager/product-requirements-document/prd.ts @@ -4,10 +4,16 @@ import { prompts } from './prompt'; import { ModelProvider } from 'src/common/model-provider'; import { Logger } from '@nestjs/common'; import { removeCodeBlockFences } from 'src/build-system/utils/strings'; +import { + MissingConfigurationError, + ModelUnavailableError, + ResponseParsingError, +} from 'src/build-system/errors'; export class PRDHandler implements BuildHandler { readonly id = 'op:PRD'; readonly logger: Logger = new Logger('PRDHandler'); + async run(context: BuilderContext): Promise { this.logger.log('Generating PRD...'); @@ -18,6 +24,17 @@ export class PRDHandler implements BuildHandler { context.getGlobalContext('description') || 'Default Description'; const platform = context.getGlobalContext('platform') || 'Default Platform'; + // Validate extracted data + if (!projectName || typeof projectName !== 'string') { + throw new MissingConfigurationError('Missing or invalid projectName.'); + } + if (!description || typeof description !== 'string') { + throw new MissingConfigurationError('Missing or invalid description.'); + } + if (!platform || typeof platform !== 'string') { + throw new MissingConfigurationError('Missing or invalid platform.'); + } + // Generate the prompt dynamically const prompt = prompts.generatePRDPrompt( projectName, @@ -25,22 +42,42 @@ export class PRDHandler implements BuildHandler { platform, ); - // Send the prompt to the LLM server and process the response - const prdContent = await this.generatePRDFromLLM(prompt); + try { + // Send the prompt to the LLM server and process the response + const prdContent = await this.generatePRDFromLLM(prompt); + + if (!prdContent || prdContent.trim() === '') { + throw new ResponseParsingError('Generated PRD content is empty.'); + } - return { - success: true, - data: removeCodeBlockFences(prdContent), - }; + return { + success: true, + data: removeCodeBlockFences(prdContent), + }; + } catch (error) { + this.logger.error('Error during PRD generation:', error); + throw new ResponseParsingError('Failed to generate PRD.'); + } } private async generatePRDFromLLM(prompt: string): Promise { - const modelProvider = ModelProvider.getInstance(); - const prdContent = await modelProvider.chatSync({ - model: 'gpt-4o-mini', - messages: [{ content: prompt, role: 'system' }], - }); - this.logger.log('Received full PRD content from LLM server.'); - return prdContent; + try { + const modelProvider = ModelProvider.getInstance(); + const prdContent = await modelProvider.chatSync({ + model: 'gpt-4o-mini', + messages: [{ content: prompt, role: 'system' }], + }); + + if (!prdContent || prdContent.trim() === '') { + throw new ModelUnavailableError( + 'LLM server returned empty PRD content.', + ); + } + + this.logger.log('Received full PRD content from LLM server.'); + return prdContent; + } catch (error) { + throw error; + } } } diff --git a/backend/src/build-system/handlers/ux/datamap/index.ts b/backend/src/build-system/handlers/ux/datamap/index.ts index 4983968..66a366c 100644 --- a/backend/src/build-system/handlers/ux/datamap/index.ts +++ b/backend/src/build-system/handlers/ux/datamap/index.ts @@ -4,34 +4,61 @@ import { ModelProvider } from 'src/common/model-provider'; import { prompts } from './prompt'; import { Logger } from '@nestjs/common'; import { removeCodeBlockFences } from 'src/build-system/utils/strings'; +import { + MissingConfigurationError, + ResponseParsingError, +} from 'src/build-system/errors'; /** * Handler for generating the UX Data Map document. */ export class UXDatamapHandler implements BuildHandler { readonly id = 'op:UX:DATAMAP:DOC'; + private readonly logger = new Logger('UXDatamapHandler'); async run(context: BuilderContext): Promise> { + this.logger.log('Generating UX Data Map Document...'); + // Extract relevant data from the context const projectName = context.getGlobalContext('projectName') || 'Default Project Name'; const sitemapDoc = context.getNodeData('op:UX:SMD'); + // Validate required data + if (!projectName || typeof projectName !== 'string') { + throw new MissingConfigurationError('Missing or invalid projectName.'); + } + if (!sitemapDoc || typeof sitemapDoc !== 'string') { + throw new MissingConfigurationError('Missing or invalid sitemapDoc.'); + } + + // Generate the prompt const prompt = prompts.generateUXDataMapPrompt( projectName, sitemapDoc, 'web', // TODO: change platform dynamically if needed ); - const uxDatamapContent = await context.model.chatSync({ - model: 'gpt-4o-mini', - messages: [{ content: prompt, role: 'system' }], - }); - Logger.log('UX Data Map Content: ', uxDatamapContent); + try { + // Generate UX Data Map content using the language model + const uxDatamapContent = await context.model.chatSync({ + model: 'gpt-4o-mini', + messages: [{ content: prompt, role: 'system' }], + }); + + if (!uxDatamapContent || uxDatamapContent.trim() === '') { + throw new ResponseParsingError( + 'Generated UX Data Map content is empty.', + ); + } - return { - success: true, - data: removeCodeBlockFences(uxDatamapContent), - }; + this.logger.log('Successfully generated UX Data Map content.'); + return { + success: true, + data: removeCodeBlockFences(uxDatamapContent), + }; + } catch (error) { + throw error; + } } } diff --git a/backend/src/build-system/handlers/ux/sitemap-document/uxsmd.ts b/backend/src/build-system/handlers/ux/sitemap-document/uxsmd.ts index b2fc062..cdf32e2 100644 --- a/backend/src/build-system/handlers/ux/sitemap-document/uxsmd.ts +++ b/backend/src/build-system/handlers/ux/sitemap-document/uxsmd.ts @@ -4,10 +4,14 @@ import { prompts } from './prompt'; import { ModelProvider } from 'src/common/model-provider'; import { Logger } from '@nestjs/common'; import { removeCodeBlockFences } from 'src/build-system/utils/strings'; +import { + MissingConfigurationError, + ResponseParsingError, +} from 'src/build-system/errors'; export class UXSMDHandler implements BuildHandler { readonly id = 'op:UX:SMD'; - readonly logger: Logger = new Logger('UXSMDHandler'); + private readonly logger = new Logger('UXSMDHandler'); async run(context: BuilderContext): Promise> { this.logger.log('Generating UXSMD...'); @@ -18,6 +22,17 @@ export class UXSMDHandler implements BuildHandler { const platform = context.getGlobalContext('platform') || 'Default Platform'; const prdContent = context.getNodeData('op:PRD'); + // Validate required data + if (!projectName || typeof projectName !== 'string') { + throw new MissingConfigurationError('Missing or invalid projectName.'); + } + if (!platform || typeof platform !== 'string') { + throw new MissingConfigurationError('Missing or invalid platform.'); + } + if (!prdContent || typeof prdContent !== 'string') { + throw new MissingConfigurationError('Missing or invalid PRD content.'); + } + // Generate the prompt dynamically const prompt = prompts.generateUxsmdrompt( projectName, @@ -25,29 +40,64 @@ export class UXSMDHandler implements BuildHandler { platform, ); - // Send the prompt to the LLM server and process the response - const uxsmdContent = await this.generateUXSMDFromLLM(prompt); + try { + // Generate UXSMD content using the language model + const uxsmdContent = await this.generateUXSMDFromLLM(prompt); + + if (!uxsmdContent || uxsmdContent.trim() === '') { + this.logger.error('Generated UXSMD content is empty.'); + throw new ResponseParsingError('Generated UXSMD content is empty.'); + } - // Store the generated document in the context - context.setGlobalContext('uxsmdDocument', uxsmdContent); + // Store the generated document in the context + context.setGlobalContext('uxsmdDocument', uxsmdContent); - // Return the generated document - return { - success: true, - data: removeCodeBlockFences(uxsmdContent), - }; + this.logger.log('Successfully generated UXSMD content.'); + return { + success: true, + data: removeCodeBlockFences(uxsmdContent), + }; + } catch (error) { + throw error; + } } private async generateUXSMDFromLLM(prompt: string): Promise { const modelProvider = ModelProvider.getInstance(); const model = 'gpt-4o-mini'; - const uxsmdContent = await modelProvider.chatSync({ - model, - messages: [{ content: prompt, role: 'system' }], - }); + try { + const uxsmdContent = await modelProvider.chatSync({ + model, + messages: [{ content: prompt, role: 'system' }], + }); + + this.logger.log('Received full UXSMD content from LLM server.'); + return uxsmdContent; + } catch (error) { + if (error.message.includes('timeout')) { + throw new ResponseParsingError( + 'Timeout occurred while communicating with the model.', + ); + } + if (error.message.includes('service unavailable')) { + throw new ResponseParsingError( + 'Model service is temporarily unavailable.', + ); + } + if (error.message.includes('rate limit')) { + throw new ResponseParsingError( + 'Rate limit exceeded while communicating with the model.', + ); + } - this.logger.log('Received full UXSMD content from LLM server.'); - return uxsmdContent; + this.logger.error( + 'Unexpected error communicating with the LLM server:', + error, + ); + throw new ResponseParsingError( + 'Failed to communicate with the LLM server.', + ); + } } } diff --git a/backend/src/build-system/handlers/ux/sitemap-structure/index.ts b/backend/src/build-system/handlers/ux/sitemap-structure/index.ts index 4d08099..8015088 100644 --- a/backend/src/build-system/handlers/ux/sitemap-structure/index.ts +++ b/backend/src/build-system/handlers/ux/sitemap-structure/index.ts @@ -4,41 +4,62 @@ import { ModelProvider } from 'src/common/model-provider'; import { prompts } from './prompt'; import { Logger } from '@nestjs/common'; import { removeCodeBlockFences } from 'src/build-system/utils/strings'; +import { + MissingConfigurationError, + ResponseParsingError, +} from 'src/build-system/errors'; // UXSMS: UX Sitemap Structure export class UXSitemapStructureHandler implements BuildHandler { readonly id = 'op:UX:SMS'; - readonly logger = new Logger('UXSitemapStructureHandler'); + private readonly logger = new Logger('UXSitemapStructureHandler'); async run(context: BuilderContext): Promise> { - this.logger.log('Generating UX Structure Document...'); + this.logger.log('Generating UX Sitemap Structure Document...'); - // extract relevant data from the context + // Extract relevant data from the context const projectName = context.getGlobalContext('projectName') || 'Default Project Name'; const sitemapDoc = context.getNodeData('op:UX:SMD'); - if (!sitemapDoc) { - return { - success: false, - error: new Error('Missing required parameters: sitemap'), - }; + // Validate required parameters + if (!projectName || typeof projectName !== 'string') { + throw new MissingConfigurationError('Missing or invalid projectName.'); + } + if (!sitemapDoc || typeof sitemapDoc !== 'string') { + throw new MissingConfigurationError( + 'Missing or invalid sitemap document.', + ); } + // Generate the prompt dynamically const prompt = prompts.generateUXSiteMapStructrePrompt( projectName, sitemapDoc, 'web', // TODO: Change platform dynamically if necessary ); - const uxStructureContent = await context.model.chatSync({ - model: 'gpt-4o-mini', - messages: [{ content: prompt, role: 'system' }], - }); + try { + // Generate UX Structure content using the language model + const uxStructureContent = await context.model.chatSync({ + model: 'gpt-4o-mini', + messages: [{ content: prompt, role: 'system' }], + }); + + if (!uxStructureContent || uxStructureContent.trim() === '') { + this.logger.error('Generated UX Sitemap Structure content is empty.'); + throw new ResponseParsingError( + 'Generated UX Sitemap Structure content is empty.', + ); + } - return { - success: true, - data: removeCodeBlockFences(uxStructureContent), - }; + this.logger.log('Successfully generated UX Sitemap Structure content.'); + return { + success: true, + data: removeCodeBlockFences(uxStructureContent), + }; + } catch (error) { + throw error; + } } } diff --git a/backend/src/build-system/handlers/ux/sitemap-structure/sms-page.ts b/backend/src/build-system/handlers/ux/sitemap-structure/sms-page.ts index 28543cd..5cc2eab 100644 --- a/backend/src/build-system/handlers/ux/sitemap-structure/sms-page.ts +++ b/backend/src/build-system/handlers/ux/sitemap-structure/sms-page.ts @@ -4,10 +4,14 @@ import { BuildHandler, BuildResult } from 'src/build-system/types'; import { ModelProvider } from 'src/common/model-provider'; import { prompts } from './prompt'; import { removeCodeBlockFences } from 'src/build-system/utils/strings'; +import { + MissingConfigurationError, + ResponseParsingError, +} from 'src/build-system/errors'; export class Level2UXSitemapStructureHandler implements BuildHandler { readonly id = 'op:UX:SMS:LEVEL2'; - readonly logger = new Logger('Level2UXSitemapStructureHandler'); + private readonly logger = new Logger('Level2UXSitemapStructureHandler'); async run(context: BuilderContext): Promise> { this.logger.log('Generating Level 2 UX Sitemap Structure Document...'); @@ -17,24 +21,33 @@ export class Level2UXSitemapStructureHandler implements BuildHandler { const sitemapDoc = context.getNodeData('op:UX:SMS'); const uxStructureDoc = context.getNodeData('op:UX:SMS'); - if (!projectName || !sitemapDoc || !uxStructureDoc) { - return { - success: false, - data: 'Missing required arguments: projectName, sitemapDoc, or uxStructureDoc.', - }; + // Validate required data + if (!projectName || typeof projectName !== 'string') { + throw new MissingConfigurationError('Missing or invalid projectName.'); + } + if (!sitemapDoc || typeof sitemapDoc !== 'string') { + throw new MissingConfigurationError( + 'Missing or invalid sitemap document.', + ); + } + if (!uxStructureDoc || typeof uxStructureDoc !== 'string') { + throw new MissingConfigurationError( + 'Missing or invalid UX Structure document.', + ); } + const normalizedUxStructureDoc = uxStructureDoc.replace(/\r\n/g, '\n'); + // Extract sections from the UX Structure Document - const sections = this.extractAllSections(uxStructureDoc); + const sections = this.extractAllSections(normalizedUxStructureDoc); if (sections.length === 0) { this.logger.error( 'No valid sections found in the UX Structure Document.', ); - return { - success: false, - data: 'No valid sections found in the UX Structure Document.', - }; + throw new ResponseParsingError( + 'No valid sections found in the UX Structure Document.', + ); } // Process each section with the refined Level 2 prompt @@ -54,6 +67,16 @@ export class Level2UXSitemapStructureHandler implements BuildHandler { messages: [{ content: prompt, role: 'system' }], }); + this.logger.log(refinedContent); + if (!refinedContent || refinedContent.trim() === '') { + this.logger.error( + `Generated content for section "${section.title}" is empty.`, + ); + throw new ResponseParsingError( + `Generated content for section "${section.title}" is empty.`, + ); + } + refinedSections.push({ title: section.title, content: refinedContent, @@ -65,7 +88,9 @@ export class Level2UXSitemapStructureHandler implements BuildHandler { .map((section) => `## **${section.title}**\n${section.content}`) .join('\n\n'); - this.logger.log(refinedDocument); + this.logger.log( + 'Successfully generated Level 2 UX Sitemap Structure document.', + ); return { success: true, @@ -81,16 +106,29 @@ export class Level2UXSitemapStructureHandler implements BuildHandler { private extractAllSections( text: string, ): Array<{ title: string; content: string }> { - const regex = /## \*\*(\d+\.\s.*)\*\*([\s\S]*?)(?=\n## \*\*|$)/g; + // Updated regex to handle optional numbering and use multiline flag + const regex = + /^##\s+(?:\d+(?:\.\d+)?\s+)?(.*?)(?=\r?\n##|$)([\s\S]*?)(?=\r?\n##|$)/gm; const sections = []; - let match; + let match = regex.exec(text); + let nextMatch; - while ((match = regex.exec(text)) !== null) { + while ((nextMatch = regex.exec(text)) !== null) { + const content = text.slice(match.index, nextMatch.index).trim(); + const title = match[1].trim(); + match = nextMatch; + sections.push({ title, content }); + } + if (match) { + const content = text.slice(match.index).trim(); const title = match[1].trim(); - const content = match[2].trim(); sections.push({ title, content }); } + if (sections.length === 0) { + this.logger.warn('No sections found in the UX Structure document.'); + } + return sections; } } diff --git a/backend/src/build-system/retry-handler.ts b/backend/src/build-system/retry-handler.ts new file mode 100644 index 0000000..93a5d86 --- /dev/null +++ b/backend/src/build-system/retry-handler.ts @@ -0,0 +1,136 @@ +// RetryHandler.ts + +import { Logger } from '@nestjs/common'; +import { RetryableError } from './errors'; + +/** + * RetryHandler is a singleton class responsible for managing retry logic + * for operations that may fail due to transient (temporary) errors. + */ +export class RetryHandler { + /** + * Logger instance for logging information, warnings, and errors. + */ + private readonly logger = new Logger(RetryHandler.name); + + /** + * Singleton instance of RetryHandler. + */ + private static instance: RetryHandler; + + /** + * A map to keep track of retry counts for different error types. + * The key is the error name, and the value is the number of retries attempted. + */ + private retryCounts: Map = new Map(); + + /** + * Maximum number of retry attempts allowed for a retryable error. + */ + private readonly MAX_RETRY_COUNT = 3; + + /** + * Private constructor to enforce the singleton pattern. + * Prevents direct instantiation of the class. + */ + private constructor() {} + + /** + * Retrieves the singleton instance of RetryHandler. + * If an instance does not exist, it creates one. + * @returns {RetryHandler} The singleton instance of RetryHandler. + */ + public static getInstance(): RetryHandler { + if (!RetryHandler.instance) { + RetryHandler.instance = new RetryHandler(); + } + return RetryHandler.instance; + } + + /** + * Adds a delay before the next retry attempt. + * The delay increases linearly based on the current retry count. + * @param {number} retryCount - The current retry attempt count. + * @returns {Promise} A promise that resolves after the specified delay. + */ + private async addRetryDelay(retryCount: number): Promise { + const delay = (retryCount + 1) * 1000; // Linear backoff: 1s, 2s, 3s + return new Promise((resolve) => setTimeout(resolve, delay)); + } + + /** + * Attempts to retry a failed method based on the type of error encountered. + * If the error is retryable, it will retry the method up to MAX_RETRY_COUNT times. + * If the error is non-retryable or the maximum retry count is reached, + * it will log the appropriate message and throw an error. + * + * @template T - The return type of the upperMethod. + * @param {Error} error - The error that was thrown by the failed method. + * @param {(...args: any[]) => Promise} upperMethod - The method to retry. + * @param {any[]} args - The arguments to pass to the upperMethod. + * @returns {Promise} The result of the successfully retried method. + * @throws {Error} If the maximum retry attempts are reached or a non-retryable error occurs. + */ + public async retryMethod( + error: Error, + upperMethod: (...args: any[]) => Promise, + args: any[], + ): Promise { + // Check if the error is an instance of RetryableError + if (error instanceof RetryableError) { + const errorName = error.name; + let retryCount = this.retryCounts.get(errorName) || 0; + + // Log a warning indicating a retryable error has occurred + this.logger.warn( + `Retryable error occurred: ${error.message}. Retrying...`, + ); + + // Attempt to retry the method while the retry count is below the maximum + while (retryCount < this.MAX_RETRY_COUNT) { + try { + // Add a delay before retrying + await this.addRetryDelay(retryCount); + + // Log the current retry attempt + this.logger.log(`Retry attempt ${retryCount + 1} for ${errorName}`); + + // Attempt to execute the upperMethod with the provided arguments + const result = await upperMethod(...args); + + // If successful, remove the retry count for this error and return the result + this.retryCounts.delete(errorName); + return result; + } catch (e) { + // If the caught error is the same retryable error, increment the retry count + if (e instanceof RetryableError && e.name === errorName) { + retryCount++; + this.retryCounts.set(errorName, retryCount); + + // Log a warning for the failed retry attempt + this.logger.warn( + `Retryable error occurred: ${e.name}: ${e.message}. Retrying attempt ${retryCount}...`, + ); + } else { + // If the error is non-retryable, log an error and rethrow the exception + this.logger.error( + `Non-retryable error occurred: ${e.name}: ${e.message}. Terminating process.`, + ); + throw e; + } + } + } + + // After exhausting all retry attempts, remove the retry count and log an error + this.retryCounts.delete(errorName); + this.logger.error('Max retry attempts reached. Terminating process.'); + throw new Error('Max retry attempts reached'); + } else { + // If the error is non-retryable, log an error and rethrow the exception + this.logger.error( + `Non-retryable error occurred: ${error.name}: ${error.message}. Terminating process.`, + ); + throw error; + } + } +}