MCP GitHub Issue Server

import { Database } from 'sqlite'; import { Logger } from '../../../logging/index.js'; import { TaskErrorFactory } from '../../../errors/task-error.js'; import { Task, TaskType, TaskStatus, CreateTaskInput, UpdateTaskInput, } from '../../../types/task.js'; import { SqliteConnection } from '../database/connection.js'; import { formatTimestamp } from '../../../utils/date-formatter.js'; export class TaskOperations { protected readonly logger: Logger; constructor(protected readonly connection: SqliteConnection) { this.logger = Logger.getInstance().child({ component: 'TaskOperations' }); this.logger.debug('TaskOperations initialized'); } /** * Create a new task */ async createTask(input: CreateTaskInput): Promise<Task> { try { if (!input.path || !input.name || !input.type) { throw TaskErrorFactory.createTaskValidationError( 'TaskOperations.createTask', 'Missing required fields: path, name, and type are required', { input } ); } const now = Date.now(); const pathParts = input.path.split('/'); const projectPath = pathParts[0]; // Automatically set parentPath based on path structure if not provided const parentPath = input.parentPath || (pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : undefined); const task: Task = { // System fields id: `task_${now}_${Math.random().toString(36).substr(2, 9)}`, path: input.path, name: input.name, type: input.type, status: TaskStatus.PENDING, created: formatTimestamp(now), updated: formatTimestamp(now), version: 1, projectPath, // Optional fields description: input.description, parentPath: parentPath, // Use automatically determined parentPath reasoning: input.reasoning, dependencies: input.dependencies || [], // Status metadata statusMetadata: input.statusMetadata || {}, // Note categories - ensure arrays are initialized planningNotes: input.planningNotes || [], progressNotes: input.progressNotes || [], completionNotes: input.completionNotes || [], troubleshootingNotes: input.troubleshootingNotes || [], // User metadata metadata: input.metadata || {}, }; // Verify parent exists if parentPath is set if (task.parentPath) { const parent = await this.getTask(task.parentPath); if (!parent) { throw TaskErrorFactory.createTaskValidationError( 'TaskOperations.createTask', `Parent task not found: ${task.parentPath}`, { input, parentPath: task.parentPath } ); } } await this.internalSaveTask(task); this.logger.info('Task created successfully', { path: task.path, type: task.type, parentPath: task.parentPath, }); return task; } catch (error) { this.logger.error('Failed to create task', { error, context: { path: input.path, name: input.name, type: input.type, parentPath: input.parentPath, }, }); throw TaskErrorFactory.createTaskCreationError( 'TaskOperations.createTask', error instanceof Error ? error : new Error(String(error)), { input } ); } } /** * Update an existing task */ async updateTask(path: string, updates: UpdateTaskInput): Promise<Task> { try { const result = await this.connection.executeInTransaction(async () => { const existingTask = await this.getTask(path); if (!existingTask) { throw TaskErrorFactory.createTaskNotFoundError('TaskOperations.updateTask', path); } const now = Date.now(); // Convert null to undefined for parentPath const parentPath = updates.parentPath === null ? undefined : typeof updates.parentPath === 'string' ? updates.parentPath : existingTask.parentPath; // Create updated task with proper type handling const updatedTask: Task = { ...existingTask, ...updates, // Update system fields updated: formatTimestamp(now), version: existingTask.version + 1, // Handle parentPath explicitly to ensure correct type parentPath, // Keep user metadata separate metadata: { ...existingTask.metadata, ...updates.metadata, }, // Handle dependencies explicitly dependencies: updates.dependencies !== undefined ? updates.dependencies : existingTask.dependencies, // Note categories - preserve existing notes if not updated planningNotes: updates.planningNotes || existingTask.planningNotes, progressNotes: updates.progressNotes || existingTask.progressNotes, completionNotes: updates.completionNotes || existingTask.completionNotes, troubleshootingNotes: updates.troubleshootingNotes || existingTask.troubleshootingNotes, // Status metadata statusMetadata: { ...existingTask.statusMetadata, ...updates.statusMetadata, }, }; // Handle parent-child relationship changes if (updates.parentPath !== undefined && updates.parentPath !== existingTask.parentPath) { await this.updateTaskRelationships(path, existingTask.parentPath, parentPath); } // Verify dependencies exist before saving if (updatedTask.dependencies && updatedTask.dependencies.length > 0) { for (const depPath of updatedTask.dependencies) { const depExists = await this.getTask(depPath); if (!depExists) { throw TaskErrorFactory.createTaskDependencyError( 'TaskOperations.updateTask', `Dependency not found: ${depPath}`, { taskId: updatedTask.id, dependencyPath: depPath } ); } } } await this.internalSaveTask(updatedTask); // Get fresh task with dependencies from database const savedTask = await this.getTask(path); if (!savedTask) { throw TaskErrorFactory.createTaskNotFoundError('TaskOperations.updateTask', path); } this.logger.info('Task updated successfully', { path, newStatus: updates.status, newParentPath: parentPath, dependencies: savedTask.dependencies, }); return savedTask; }); if (!result) { throw TaskErrorFactory.createTaskNotFoundError('TaskOperations.updateTask', path); } return result; } catch (error) { this.logger.error('Failed to update task', { error, context: { path, updates, }, }); throw TaskErrorFactory.createTaskUpdateError( 'TaskOperations.updateTask', error instanceof Error ? error : new Error(String(error)), { path, updates } ); } } /** * Get a task by path */ async getTask(path: string): Promise<Task | null> { try { return await this.connection.execute(async db => { // Get task data const row = await db.get<Record<string, unknown>>( 'SELECT * FROM tasks WHERE path = ?', path ); if (!row) { this.logger.debug('Task not found', { path }); return null; } // Get dependencies const dependencies = await db.all<{ dependency_path: string }[]>( 'SELECT dependency_path FROM task_dependencies WHERE task_id = ?', row.id ); // Add dependencies to row data row.dependencies = JSON.stringify(dependencies.map(d => d.dependency_path)); return this.rowToTask(row); }, 'getTask'); } catch (error) { this.logger.error('Failed to get task', { error, context: { path }, }); throw TaskErrorFactory.createTaskStorageError( 'TaskOperations.getTask', error instanceof Error ? error : new Error(String(error)), { path } ); } } /** * Get multiple tasks by paths */ async getTasks(paths: string[]): Promise<Task[]> { if (paths.length === 0) return []; try { return await this.connection.execute(async db => { const placeholders = paths.map(() => '?').join(','); // Get tasks const rows = await db.all<Record<string, unknown>[]>( `SELECT * FROM tasks WHERE path IN (${placeholders})`, ...paths ); // Get task IDs for dependency lookup const taskIds = rows.map(row => String(row.id)); // Get dependencies for all tasks const dependencies = await db.all<{ task_id: string; dependency_path: string }[]>( `SELECT task_id, dependency_path FROM task_dependencies WHERE task_id IN (${placeholders})`, ...taskIds ); // Group dependencies by task const dependenciesByTask = dependencies.reduce( (acc, dep) => { acc[dep.task_id] = acc[dep.task_id] || []; acc[dep.task_id].push(dep.dependency_path); return acc; }, {} as Record<string, string[]> ); // Add dependencies to each row const rowsWithDeps = rows.map(row => ({ ...row, dependencies: JSON.stringify(dependenciesByTask[String(row.id)] || []), })); this.logger.debug('Retrieved multiple tasks', { count: rows.length }); return rowsWithDeps.map(row => this.rowToTask(row)); }, 'getTasks'); } catch (error) { this.logger.error('Failed to get tasks', { error, context: { paths }, }); throw TaskErrorFactory.createTaskStorageError( 'TaskOperations.getTasks', error instanceof Error ? error : new Error(String(error)), { paths } ); } } /** * Get tasks by pattern */ async getTasksByPattern(pattern: string): Promise<Task[]> { try { return await this.connection.execute(async db => { const sqlPattern = pattern.replace(/\*/g, '%').replace(/\?/g, '_'); // Get tasks matching pattern const rows = await db.all<Record<string, unknown>[]>( 'SELECT * FROM tasks WHERE path LIKE ?', sqlPattern ); // Get task IDs for dependency lookup const taskIds = rows.map(row => String(row.id)); if (taskIds.length === 0) { return []; } // Get dependencies for matched tasks const placeholders = taskIds.map(() => '?').join(','); const dependencies = await db.all<{ task_id: string; dependency_path: string }[]>( `SELECT task_id, dependency_path FROM task_dependencies WHERE task_id IN (${placeholders})`, ...taskIds ); // Group dependencies by task const dependenciesByTask = dependencies.reduce( (acc, dep) => { acc[dep.task_id] = acc[dep.task_id] || []; acc[dep.task_id].push(dep.dependency_path); return acc; }, {} as Record<string, string[]> ); // Add dependencies to each row const rowsWithDeps = rows.map(row => ({ ...row, dependencies: JSON.stringify(dependenciesByTask[String(row.id)] || []), })); this.logger.debug('Retrieved tasks by pattern', { pattern, count: rows.length }); return rowsWithDeps.map(row => this.rowToTask(row)); }, 'getTasksByPattern'); } catch (error) { this.logger.error('Failed to get tasks by pattern', { error, context: { pattern }, }); throw TaskErrorFactory.createTaskStorageError( 'TaskOperations.getTasksByPattern', error instanceof Error ? error : new Error(String(error)), { pattern } ); } } /** * Get tasks by status */ async getTasksByStatus(status: TaskStatus): Promise<Task[]> { try { return await this.connection.execute(async db => { // Get tasks with status const rows = await db.all<Record<string, unknown>[]>( 'SELECT * FROM tasks WHERE status = ?', status ); // Get task IDs for dependency lookup const taskIds = rows.map(row => String(row.id)); if (taskIds.length === 0) { return []; } // Get dependencies for matched tasks const placeholders = taskIds.map(() => '?').join(','); const dependencies = await db.all<{ task_id: string; dependency_path: string }[]>( `SELECT task_id, dependency_path FROM task_dependencies WHERE task_id IN (${placeholders})`, ...taskIds ); // Group dependencies by task const dependenciesByTask = dependencies.reduce( (acc, dep) => { acc[dep.task_id] = acc[dep.task_id] || []; acc[dep.task_id].push(dep.dependency_path); return acc; }, {} as Record<string, string[]> ); // Add dependencies to each row const rowsWithDeps = rows.map(row => ({ ...row, dependencies: JSON.stringify(dependenciesByTask[String(row.id)] || []), })); this.logger.debug('Retrieved tasks by status', { status, count: rows.length }); return rowsWithDeps.map(row => this.rowToTask(row)); }, 'getTasksByStatus'); } catch (error) { this.logger.error('Failed to get tasks by status', { error, context: { status }, }); throw TaskErrorFactory.createTaskStorageError( 'TaskOperations.getTasksByStatus', error instanceof Error ? error : new Error(String(error)), { status } ); } } /** * Get child tasks of a task */ async getChildren(parentPath: string): Promise<Task[]> { try { return await this.connection.execute(async db => { // Get immediate children using task_hierarchy view const rows = await db.all<Record<string, unknown>[]>( `SELECT t.*, tdv.dependencies, tdv.dependency_count, tdv.completed_dependencies FROM task_hierarchy h JOIN tasks t ON t.path = h.path LEFT JOIN task_dependencies_view tdv ON t.id = tdv.id WHERE h.parent_path = ? AND h.depth = 1`, parentPath ); this.logger.debug('Retrieved child tasks', { parentPath, count: rows.length, childPaths: rows.map(r => r.path), }); return rows.map(row => this.rowToTask({ ...row, dependencies: row.dependencies || '[]', }) ); }, 'getChildren'); } catch (error) { this.logger.error('Failed to get child tasks', { error, context: { parentPath }, }); throw TaskErrorFactory.createTaskStorageError( 'TaskOperations.getChildren', error instanceof Error ? error : new Error(String(error)), { parentPath } ); } } /** * Delete a task */ async deleteTask(path: string): Promise<void> { try { await this.deleteTasks([path]); this.logger.info('Task deleted', { path }); } catch (error) { this.logger.error('Failed to delete task', { error, context: { path }, }); throw TaskErrorFactory.createTaskStorageError( 'TaskOperations.deleteTask', error instanceof Error ? error : new Error(String(error)), { path } ); } } /** * Delete multiple tasks */ async deleteTasks(paths: string[]): Promise<void> { if (paths.length === 0) return; try { await this.connection.execute(async db => { const placeholders = paths.map(() => '?').join(','); await db.run(`DELETE FROM tasks WHERE path IN (${placeholders})`, ...paths); this.logger.info('Multiple tasks deleted', { count: paths.length }); }, 'deleteTasks'); } catch (error) { this.logger.error('Failed to delete tasks', { error, context: { paths }, }); throw TaskErrorFactory.createTaskStorageError( 'TaskOperations.deleteTasks', error instanceof Error ? error : new Error(String(error)), { paths } ); } } /** * Save a task */ async saveTask(task: Task): Promise<void> { await this.internalSaveTask(task); } /** * Save multiple tasks */ async saveTasks(tasks: Task[]): Promise<void> { await this.internalSaveTasks(tasks); } /** * Internal save task implementation */ protected async internalSaveTask(task: Task): Promise<void> { await this.internalSaveTasks([task]); } /** * Internal save tasks implementation */ protected async internalSaveTasks(tasks: Task[]): Promise<void> { try { await this.connection.execute(async db => { for (const task of tasks) { await this.saveTaskToDb(db, task); } this.logger.debug('Tasks saved', { count: tasks.length }); }, 'saveTasks'); } catch (error) { this.logger.error('Failed to save tasks', { error, context: { taskCount: tasks.length }, }); throw TaskErrorFactory.createTaskStorageError( 'TaskOperations.internalSaveTasks', error instanceof Error ? error : new Error(String(error)), { taskCount: tasks.length } ); } } /** * Save a task to the database */ private async saveTaskToDb(db: Database, task: Task): Promise<void> { // First save the task without dependencies const sql = ` INSERT OR REPLACE INTO tasks ( id, path, name, description, type, status, parent_path, reasoning, project_path, metadata, status_metadata, planning_notes, progress_notes, completion_notes, troubleshooting_notes, created_at, updated_at, version ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; await db.run(sql, [ task.id, task.path, task.name, task.description, task.type, task.status, task.parentPath, task.reasoning, task.projectPath, JSON.stringify(task.metadata), JSON.stringify(task.statusMetadata), JSON.stringify(task.planningNotes), JSON.stringify(task.progressNotes), JSON.stringify(task.completionNotes), JSON.stringify(task.troubleshootingNotes), task.created, task.updated, task.version, ]); // Then handle dependencies separately using the task_dependencies table // First delete existing dependencies await db.run('DELETE FROM task_dependencies WHERE task_id = ?', task.id); // Then insert new dependencies if (task.dependencies && task.dependencies.length > 0) { const now = Date.now(); // First verify all dependencies exist for (const depPath of task.dependencies) { const depExists = await db.get('SELECT path FROM tasks WHERE path = ?', depPath); if (!depExists) { throw TaskErrorFactory.createTaskDependencyError( 'TaskOperations.saveTaskToDb', `Dependency not found: ${depPath}`, { taskId: task.id, dependencyPath: depPath } ); } } // Then insert dependencies for (const depPath of task.dependencies) { await db.run( 'INSERT INTO task_dependencies (task_id, dependency_path, created_at) VALUES (?, ?, ?)', task.id, depPath, now ); } } } /** * Update task relationships when parent path changes */ private async updateTaskRelationships( taskPath: string, oldParentPath: string | undefined, newParentPath: string | undefined ): Promise<void> { try { // Update old parent if it exists if (oldParentPath) { const oldParent = await this.getTask(oldParentPath); if (oldParent) { await this.internalSaveTask({ ...oldParent, updated: formatTimestamp(Date.now()), version: oldParent.version + 1, }); this.logger.debug('Removed task from old parent', { taskPath, oldParentPath }); } } // Update new parent if it exists if (newParentPath) { const newParent = await this.getTask(newParentPath); if (newParent) { await this.internalSaveTask({ ...newParent, updated: formatTimestamp(Date.now()), version: newParent.version + 1, }); this.logger.debug('Added task to new parent', { taskPath, newParentPath }); } } } catch (error) { this.logger.error('Failed to update task relationships', { error, context: { taskPath, oldParentPath, newParentPath }, }); throw TaskErrorFactory.createTaskOperationError( 'TaskOperations.updateTaskRelationships', 'Failed to update task relationships', { taskPath, oldParentPath, newParentPath } ); } } /** * Convert a database row to a Task object */ protected rowToTask(row: Record<string, unknown>): Task { return { // System fields id: String(row.id || ''), path: String(row.path || ''), name: String(row.name || ''), type: String(row.type || '') as TaskType, status: String(row.status || '') as TaskStatus, created: String(row.created_at || ''), updated: String(row.updated_at || ''), version: Number(row.version || 1), projectPath: String(row.path || '').split('/')[0], // Optional fields description: row.description ? String(row.description) : undefined, parentPath: row.parent_path ? String(row.parent_path) : undefined, reasoning: row.reasoning ? String(row.reasoning) : undefined, dependencies: this.parseJSON<string[]>(String(row.dependencies || '[]'), []), // Status metadata statusMetadata: this.parseJSON(String(row.status_metadata || '{}'), {}), // Note categories planningNotes: this.parseJSON<string[]>(String(row.planning_notes || '[]'), []), progressNotes: this.parseJSON<string[]>(String(row.progress_notes || '[]'), []), completionNotes: this.parseJSON<string[]>(String(row.completion_notes || '[]'), []), troubleshootingNotes: this.parseJSON<string[]>(String(row.troubleshooting_notes || '[]'), []), // User metadata metadata: this.parseJSON(String(row.metadata || '{}'), {}), }; } /** * Parse JSON with fallback */ protected parseJSON<T>(value: string | null | undefined, defaultValue: T): T { if (!value) return defaultValue; try { return JSON.parse(value) as T; } catch (error) { this.logger.warn('Failed to parse JSON', { value }); return defaultValue; } } }