UNPKG

taskwerk

Version:

A task management CLI for developers and AI agents working together

870 lines (735 loc) 23.6 kB
import { getDatabase } from '../db/database.js'; import { generateTaskId, generateSubtaskId, taskIdExists } from '../db/task-id.js'; import { TaskNotFoundError, DuplicateTaskIdError } from '../errors/task-errors.js'; import { ValidationError } from '../errors/base-error.js'; import { Logger } from '../logging/logger.js'; import { query } from './query-builder.js'; import { TaskValidator } from './validation.js'; import { fuzzyMatchTaskId, formatTaskNotFoundError } from '../utils/fuzzy-match.js'; export class TaskwerkAPI { constructor(database = null) { this.db = database || getDatabase(); this.logger = new Logger('api'); } /** * Get database connection */ getDatabase() { // If we have a TaskwerkDatabase instance if (this.db && typeof this.db.isConnected === 'function') { if (!this.db.isConnected()) { this.db.connect(); } return this.db.db; // Return the actual SQLite connection } // Otherwise assume it's already a raw SQLite connection return this.db; } /** * Create a new task * @param {Object} taskData - Task data * @returns {Object} Created task */ async createTask(taskData) { const db = this.getDatabase(); // Validate input data const validation = TaskValidator.validateCreate(taskData); if (!validation.isValid) { const messages = validation.errors.map(err => `${err.field}: ${err.message}`); throw new ValidationError(`Validation failed: ${messages.join(', ')}`); } // Generate ID if not provided if (!taskData.id) { if (taskData.parent_id) { // Check if parent exists this.getTask(taskData.parent_id); // Generate subtask ID like TASK-001.1 taskData.id = await generateSubtaskId(taskData.parent_id, db); } else { // Generate regular task ID like TASK-001 taskData.id = await generateTaskId('TASK', db); } } else if (taskIdExists(taskData.id, db)) { throw new DuplicateTaskIdError(taskData.id); } // Set defaults const task = { id: taskData.id, name: taskData.name, description: taskData.description || null, status: taskData.status || 'todo', priority: taskData.priority || 'medium', assignee: taskData.assignee || null, created_by: taskData.created_by || 'system', updated_by: taskData.updated_by || taskData.created_by || 'system', estimate: taskData.estimate || null, actual: taskData.actual || null, estimated: taskData.estimated || null, actual_time: taskData.actual_time || null, progress: taskData.progress || 0, parent_id: taskData.parent_id || null, branch_name: taskData.branch_name || null, due_date: taskData.due_date || null, content: taskData.content || null, category: taskData.category || null, metadata: taskData.metadata ? JSON.stringify(taskData.metadata) : '{}', context: taskData.context ? JSON.stringify(taskData.context) : '{}', }; try { const stmt = db.prepare(` INSERT INTO tasks ( id, name, description, status, priority, assignee, created_by, updated_by, estimate, actual, estimated, actual_time, progress, parent_id, branch_name, due_date, content, category, metadata, context ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `); const result = stmt.run( task.id, task.name, task.description, task.status, task.priority, task.assignee, task.created_by, task.updated_by, task.estimate, task.actual, task.estimated, task.actual_time, task.progress, task.parent_id, task.branch_name, task.due_date, task.content, task.category, task.metadata, task.context ); if (result.changes === 0) { throw new ValidationError('Failed to create task'); } this.logger.info(`Created task ${task.id}: ${task.name}`); // Add to timeline await this.addTimelineEvent(task.id, 'created', task.created_by, 'Task created'); return this.getTask(task.id); } catch (error) { this.logger.error(`Failed to create task: ${error.message}`); throw error; } } /** * Get a task by ID (case-insensitive with fuzzy matching) * @param {string} taskId - Task ID * @returns {Object} Task data */ getTask(taskId) { const db = this.getDatabase(); // Try exact match first let stmt = db.prepare('SELECT * FROM tasks WHERE id = ?'); let task = stmt.get(taskId); // If not found, try case-insensitive match if (!task) { stmt = db.prepare('SELECT * FROM tasks WHERE UPPER(id) = UPPER(?)'); task = stmt.get(taskId); } // If still not found, try fuzzy matching if (!task) { const fuzzyMatch = fuzzyMatchTaskId(taskId, db); if (fuzzyMatch) { stmt = db.prepare('SELECT * FROM tasks WHERE id = ?'); task = stmt.get(fuzzyMatch); } } if (!task) { throw new TaskNotFoundError(formatTaskNotFoundError(taskId, db)); } // Parse JSON fields try { task.metadata = JSON.parse(task.metadata || '{}'); task.context = JSON.parse(task.context || '{}'); } catch (error) { this.logger.warn(`Failed to parse JSON for task ${taskId}: ${error.message}`); task.metadata = {}; task.context = {}; } return task; } /** * Update a task * @param {string} taskId - Task ID * @param {Object} updates - Update data * @param {string} updatedBy - User making the update * @returns {Object} Updated task */ async updateTask(taskId, updates, updatedBy = 'system') { const db = this.getDatabase(); // Validate update data const validation = TaskValidator.validateUpdate(updates); if (!validation.isValid) { const messages = validation.errors.map(err => `${err.field}: ${err.message}`); throw new ValidationError(`Validation failed: ${messages.join(', ')}`); } // Check if task exists and get the actual ID (handles case-insensitive lookup) const currentTask = this.getTask(taskId); const actualTaskId = currentTask.id; // Track changes for timeline const changes = {}; const updateFields = []; const values = []; // Build dynamic update query for (const [field, value] of Object.entries(updates)) { if (field === 'id') { continue; } // Cannot update ID if (field === 'metadata' || field === 'context') { const jsonValue = typeof value === 'string' ? value : JSON.stringify(value); updateFields.push(`${field} = ?`); values.push(jsonValue); changes[field] = { old: currentTask[field], new: value }; } else { updateFields.push(`${field} = ?`); values.push(value); changes[field] = { old: currentTask[field], new: value }; } } if (updateFields.length === 0) { return currentTask; } // Add updated_by and updated_at updateFields.push('updated_by = ?'); values.push(updatedBy); const sql = `UPDATE tasks SET ${updateFields.join(', ')} WHERE id = ?`; values.push(actualTaskId); try { const stmt = db.prepare(sql); const result = stmt.run(...values); if (result.changes === 0) { throw new ValidationError('Failed to update task'); } this.logger.info(`Updated task ${actualTaskId}`); // Add to timeline await this.addTimelineEvent(actualTaskId, 'updated', updatedBy, 'Task updated', changes); return this.getTask(actualTaskId); } catch (error) { this.logger.error(`Failed to update task ${taskId}: ${error.message}`); throw error; } } /** * Delete a task * @param {string} taskId - Task ID * @param {string} deletedBy - User deleting the task * @returns {boolean} Success */ async deleteTask(taskId, _deletedBy = 'system') { const db = this.getDatabase(); // Check if task exists and get the actual ID (handles case-insensitive lookup) const task = this.getTask(taskId); const actualTaskId = task.id; try { const stmt = db.prepare('DELETE FROM tasks WHERE id = ?'); const result = stmt.run(actualTaskId); if (result.changes === 0) { throw new ValidationError('Failed to delete task'); } this.logger.info(`Deleted task ${actualTaskId}`); return true; } catch (error) { this.logger.error(`Failed to delete task ${actualTaskId}: ${error.message}`); throw error; } } /** * List tasks with optional filtering * @param {Object} options - Query options * @returns {Array} List of tasks */ listTasks(options = {}) { const db = this.getDatabase(); const conditions = []; const values = []; let joins = ''; // Build WHERE conditions if (options.status) { conditions.push('tasks.status = ?'); values.push(options.status); } if (options.priority) { conditions.push('tasks.priority = ?'); values.push(options.priority); } if (options.assignee) { conditions.push('tasks.assignee = ?'); values.push(options.assignee); } if (options.parent_id) { conditions.push('tasks.parent_id = ?'); values.push(options.parent_id); } if (options.category) { conditions.push('tasks.category = ?'); values.push(options.category); } // Handle tag filtering if (options.tags && options.tags.length > 0) { // Join with task_tags table and filter by tags joins = 'INNER JOIN task_tags ON tasks.id = task_tags.task_id'; const tagPlaceholders = options.tags.map(() => '?').join(', '); conditions.push(`task_tags.tag IN (${tagPlaceholders})`); values.push(...options.tags); } // Build ORDER BY const orderBy = options.order_by || 'created_at'; const orderDir = options.order_dir || 'DESC'; // Build LIMIT const limit = options.limit ? `LIMIT ${parseInt(options.limit)}` : ''; const offset = options.offset ? `OFFSET ${parseInt(options.offset)}` : ''; const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Build the SQL query const sql = ` SELECT DISTINCT tasks.* FROM tasks ${joins} ${whereClause} ORDER BY tasks.${orderBy} ${orderDir} ${limit} ${offset} `; try { const stmt = db.prepare(sql); const tasks = stmt.all(...values); // Parse JSON fields for each task return tasks.map(task => { try { task.metadata = JSON.parse(task.metadata || '{}'); task.context = JSON.parse(task.context || '{}'); } catch (error) { this.logger.warn(`Failed to parse JSON for task ${task.id}: ${error.message}`); task.metadata = {}; task.context = {}; } return task; }); } catch (error) { this.logger.error(`Failed to list tasks: ${error.message}`); throw error; } } /** * Add a timeline event * @param {string} taskId - Task ID * @param {string} action - Action type * @param {string} user - User performing action * @param {string} note - Optional note * @param {Object} changes - Optional changes data */ async addTimelineEvent(taskId, action, user, note = null, changes = null) { const db = this.getDatabase(); try { const stmt = db.prepare(` INSERT INTO task_timeline (task_id, action, user, note, changes) VALUES (?, ?, ?, ?, ?) `); stmt.run(taskId, action, user, note, changes ? JSON.stringify(changes) : null); } catch (error) { this.logger.error(`Failed to add timeline event for task ${taskId}: ${error.message}`); // Don't throw - timeline is not critical } } /** * Get task timeline * @param {string} taskId - Task ID * @returns {Array} Timeline events */ getTaskTimeline(taskId) { const db = this.getDatabase(); const stmt = db.prepare(` SELECT * FROM task_timeline WHERE task_id = ? ORDER BY timestamp DESC `); const events = stmt.all(taskId); return events.map(event => { try { event.changes = event.changes ? JSON.parse(event.changes) : null; } catch (error) { this.logger.warn( `Failed to parse changes for timeline event ${event.id}: ${error.message}` ); event.changes = null; } return event; }); } /** * Execute operations in a transaction * @param {Function} operations - Function containing operations * @returns {*} Result of operations */ transaction(operations) { const db = this.getDatabase(); try { // Use better-sqlite3's transaction method const transaction = db.transaction(() => { return operations(this); }); return transaction(); } catch (error) { this.logger.error(`Transaction failed: ${error.message}`); throw error; } } /** * Get query builder for advanced queries * @returns {QueryBuilder} Query builder instance */ query() { return query(this.getDatabase()).from('tasks'); } /** * Search tasks by text * @param {string} searchTerm - Search term * @param {Object} options - Search options * @returns {Array} Matching tasks */ searchTasks(searchTerm, options = {}) { const builder = this.query(); if (searchTerm) { // Group the OR conditions together const searchConditions = ['name LIKE ?', 'description LIKE ?', 'content LIKE ?'].join(' OR '); builder.whereConditions.push(`(${searchConditions})`); builder.whereValues.push(`%${searchTerm}%`, `%${searchTerm}%`, `%${searchTerm}%`); } // Apply filters if (options.status) { builder.andWhere('status', '=', options.status); } if (options.priority) { builder.andWhere('priority', '=', options.priority); } if (options.assignee) { builder.andWhere('assignee', '=', options.assignee); } if (options.category) { builder.andWhere('category', '=', options.category); } // Apply date filters if (options.created_after) { builder.andWhere('created_at', '>=', options.created_after); } if (options.created_before) { builder.andWhere('created_at', '<=', options.created_before); } if (options.due_after) { builder.andWhere('due_date', '>=', options.due_after); } if (options.due_before) { builder.andWhere('due_date', '<=', options.due_before); } // Apply ordering and pagination const orderBy = options.order_by || 'created_at'; const orderDir = options.order_dir || 'DESC'; builder.orderBy(orderBy, orderDir); if (options.limit) { builder.limit(options.limit); } if (options.offset) { builder.offset(options.offset); } const results = builder.get(); // Parse JSON fields return results.map(task => { try { task.metadata = JSON.parse(task.metadata || '{}'); task.context = JSON.parse(task.context || '{}'); } catch (error) { this.logger.warn(`Failed to parse JSON for task ${task.id}: ${error.message}`); task.metadata = {}; task.context = {}; } return task; }); } /** * Get tasks by status * @param {string} status - Task status * @param {Object} options - Query options * @returns {Array} Tasks with specified status */ getTasksByStatus(status, options = {}) { const builder = this.query() .where('status', '=', status) .orderBy(options.order_by || 'created_at', options.order_dir || 'DESC'); if (options.limit) { builder.limit(options.limit); } if (options.offset) { builder.offset(options.offset); } return builder.get(); } /** * Get tasks by assignee * @param {string} assignee - Assignee * @param {Object} options - Query options * @returns {Array} Tasks assigned to user */ getTasksByAssignee(assignee, options = {}) { const builder = this.query() .where('assignee', '=', assignee) .orderBy(options.order_by || 'created_at', options.order_dir || 'DESC'); if (options.limit) { builder.limit(options.limit); } if (options.offset) { builder.offset(options.offset); } return builder.get(); } /** * Get subtasks of a parent task * @param {string} parentId - Parent task ID * @param {Object} options - Query options * @returns {Array} Subtasks */ getSubtasks(parentId, options = {}) { return this.query() .where('parent_id', '=', parentId) .orderBy(options.order_by || 'created_at', options.order_dir || 'ASC') .get(); } /** * Get overdue tasks * @param {Object} options - Query options * @returns {Array} Overdue tasks */ getOverdueTasks(options = {}) { const now = new Date().toISOString(); const builder = this.query() .where('due_date', '<', now) .andWhere('status', 'NOT IN', ['done', 'completed', 'cancelled']) .orderBy('due_date', 'ASC'); if (options.limit) { builder.limit(options.limit); } return builder.get(); } /** * Get task statistics * @returns {Object} Task statistics */ getTaskStats() { const db = this.getDatabase(); const stats = {}; // Status counts const statusCounts = db .prepare( ` SELECT status, COUNT(*) as count FROM tasks GROUP BY status ` ) .all(); stats.by_status = {}; statusCounts.forEach(row => { stats.by_status[row.status] = row.count; }); // Priority counts const priorityCounts = db .prepare( ` SELECT priority, COUNT(*) as count FROM tasks GROUP BY priority ` ) .all(); stats.by_priority = {}; priorityCounts.forEach(row => { stats.by_priority[row.priority] = row.count; }); // Total counts stats.total = db.prepare('SELECT COUNT(*) as count FROM tasks').get().count; stats.completed = db .prepare( ` SELECT COUNT(*) as count FROM tasks WHERE status IN ('done', 'completed') ` ) .get().count; stats.in_progress = db .prepare( ` SELECT COUNT(*) as count FROM tasks WHERE status IN ('in-progress', 'in_progress') ` ) .get().count; stats.overdue = db .prepare( ` SELECT COUNT(*) as count FROM tasks WHERE due_date < datetime('now') AND status NOT IN ('done', 'completed', 'cancelled') ` ) .get().count; return stats; } /** * Add tags to a task * @param {string} taskId - Task ID * @param {Array} tags - Array of tag strings * @param {string} user - User adding tags * @returns {boolean} Success */ async addTaskTags(taskId, tags, user = 'system') { const db = this.getDatabase(); // Verify task exists this.getTask(taskId); try { const stmt = db.prepare('INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)'); for (const tag of tags) { stmt.run(taskId, tag.trim()); } this.logger.info(`Added ${tags.length} tags to task ${taskId}`); await this.addTimelineEvent(taskId, 'tags_added', user, `Added tags: ${tags.join(', ')}`); return true; } catch (error) { this.logger.error(`Failed to add tags to task ${taskId}: ${error.message}`); throw error; } } /** * Remove tags from a task * @param {string} taskId - Task ID * @param {Array} tags - Array of tag strings to remove * @param {string} user - User removing tags * @returns {boolean} Success */ async removeTaskTags(taskId, tags, user = 'system') { const db = this.getDatabase(); try { const stmt = db.prepare('DELETE FROM task_tags WHERE task_id = ? AND tag = ?'); for (const tag of tags) { stmt.run(taskId, tag.trim()); } this.logger.info(`Removed ${tags.length} tags from task ${taskId}`); await this.addTimelineEvent(taskId, 'tags_removed', user, `Removed tags: ${tags.join(', ')}`); return true; } catch (error) { this.logger.error(`Failed to remove tags from task ${taskId}: ${error.message}`); throw error; } } /** * Get task tags * @param {string} taskId - Task ID * @returns {Array} Array of tag strings */ getTaskTags(taskId) { const db = this.getDatabase(); const tags = db.prepare('SELECT tag FROM task_tags WHERE task_id = ? ORDER BY tag').all(taskId); return tags.map(row => row.tag); } /** * Get task notes * @param {string} taskId - Task ID * @returns {Array} Array of note objects */ getTaskNotes(taskId) { const db = this.getDatabase(); const notes = db .prepare( ` SELECT id, note, content, user, created_at, updated_at FROM task_notes WHERE task_id = ? ORDER BY created_at DESC ` ) .all(taskId); return notes; } /** * Add a note to a task * @param {string} taskId - Task ID * @param {string} note - Note text * @param {string} user - User adding the note * @param {string} content - Optional longer content * @returns {boolean} Success */ async addTaskNote(taskId, note, user = 'system', content = null) { const db = this.getDatabase(); // Verify task exists this.getTask(taskId); try { const stmt = db.prepare(` INSERT INTO task_notes (task_id, note, content, user) VALUES (?, ?, ?, ?) `); stmt.run(taskId, note, content, user); this.logger.info(`Added note to task ${taskId}`); await this.addTimelineEvent(taskId, 'note_added', user, `Added note: ${note}`); return true; } catch (error) { this.logger.error(`Failed to add note to task ${taskId}: ${error.message}`); throw error; } } /** * Get parent task if this is a subtask * @param {string} taskId - Task ID * @returns {Object|null} Parent task or null */ getParentTask(taskId) { const task = this.getTask(taskId); if (!task.parent_id) { return null; } return this.getTask(task.parent_id); } /** * Get task hierarchy (parent and all siblings) * @param {string} taskId - Task ID * @returns {Object} Hierarchy object with parent and siblings */ getTaskHierarchy(taskId) { const task = this.getTask(taskId); const hierarchy = { task, parent: null, siblings: [], subtasks: [], }; if (task.parent_id) { hierarchy.parent = this.getTask(task.parent_id); hierarchy.siblings = this.getSubtasks(task.parent_id).filter(t => t.id !== taskId); } hierarchy.subtasks = this.getSubtasks(taskId); return hierarchy; } /** * Get tasks that this task depends on * @param {string} taskId - Task ID * @returns {Array} Array of tasks that this task depends on */ getTaskDependencies(taskId) { const db = this.getDatabase(); const dependencies = db .prepare( ` SELECT t.id, t.name, t.status, t.priority FROM tasks t INNER JOIN task_dependencies td ON t.id = td.depends_on_id WHERE td.task_id = ? ORDER BY t.id ` ) .all(taskId); return dependencies; } }