- fixed esbuild issues - converted to npm instead of pnpm since root project is npm (might switch whole project to pnpm later)
1349 lines
32 KiB
TypeScript
1349 lines
32 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import { MCPClientManager } from './mcpClient';
|
|
import { logger } from './logger';
|
|
|
|
// Task Master MCP API response types
|
|
export interface MCPTaskResponse {
|
|
data?: {
|
|
tasks?: Array<{
|
|
id: number | string;
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string;
|
|
details?: string;
|
|
testStrategy?: string;
|
|
dependencies?: Array<number | string>;
|
|
complexityScore?: number;
|
|
subtasks?: Array<{
|
|
id: number;
|
|
title: string;
|
|
description?: string;
|
|
status: string;
|
|
details?: string;
|
|
dependencies?: Array<number | string>;
|
|
}>;
|
|
}>;
|
|
tag?: {
|
|
currentTag: string;
|
|
availableTags: string[];
|
|
};
|
|
};
|
|
version?: {
|
|
version: string;
|
|
name: string;
|
|
};
|
|
error?: string;
|
|
}
|
|
|
|
// Our internal Task interface (matches the webview expectations)
|
|
export interface TaskMasterTask {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
status:
|
|
| 'pending'
|
|
| 'in-progress'
|
|
| 'review'
|
|
| 'done'
|
|
| 'deferred'
|
|
| 'cancelled';
|
|
priority: 'high' | 'medium' | 'low';
|
|
details?: string;
|
|
testStrategy?: string;
|
|
dependencies?: string[];
|
|
complexityScore?: number;
|
|
subtasks?: Array<{
|
|
id: number;
|
|
title: string;
|
|
description?: string;
|
|
status: string;
|
|
details?: string;
|
|
dependencies?: Array<number | string>;
|
|
}>;
|
|
}
|
|
|
|
// API response wrapper
|
|
export interface TaskMasterApiResponse<T = any> {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: string;
|
|
requestDuration?: number;
|
|
}
|
|
|
|
// API configuration
|
|
export interface TaskMasterApiConfig {
|
|
timeout: number;
|
|
retryAttempts: number;
|
|
cacheDuration: number;
|
|
projectRoot?: string;
|
|
// Enhanced caching configuration
|
|
cache?: {
|
|
maxSize: number; // Maximum number of cache entries
|
|
enableBackgroundRefresh: boolean; // Enable background cache refresh
|
|
refreshInterval: number; // Background refresh interval in ms
|
|
enableAnalytics: boolean; // Track cache hit/miss statistics
|
|
enablePrefetch: boolean; // Enable prefetching of related data
|
|
compressionEnabled: boolean; // Enable data compression for large datasets
|
|
persistToDisk: boolean; // Persist cache to disk (future enhancement)
|
|
};
|
|
}
|
|
|
|
// Enhanced cache entry interface
|
|
interface CacheEntry {
|
|
data: any;
|
|
timestamp: number;
|
|
accessCount: number;
|
|
lastAccessed: number;
|
|
size: number;
|
|
ttl?: number;
|
|
tags: string[];
|
|
}
|
|
|
|
// Cache analytics interface
|
|
interface CacheAnalytics {
|
|
hits: number;
|
|
misses: number;
|
|
evictions: number;
|
|
refreshes: number;
|
|
totalSize: number;
|
|
averageAccessTime: number;
|
|
hitRate: number;
|
|
}
|
|
|
|
/**
|
|
* Task Master API client that wraps MCP tool calls
|
|
*/
|
|
export class TaskMasterApi {
|
|
private mcpClient: MCPClientManager;
|
|
private config: TaskMasterApiConfig;
|
|
private cache = new Map<string, CacheEntry>();
|
|
private cacheAnalytics: CacheAnalytics = {
|
|
hits: 0,
|
|
misses: 0,
|
|
evictions: 0,
|
|
refreshes: 0,
|
|
totalSize: 0,
|
|
averageAccessTime: 0,
|
|
hitRate: 0
|
|
};
|
|
private backgroundRefreshTimer?: NodeJS.Timeout;
|
|
private readonly defaultCacheConfig = {
|
|
maxSize: 100,
|
|
enableBackgroundRefresh: true,
|
|
refreshInterval: 5 * 60 * 1000, // 5 minutes
|
|
enableAnalytics: true,
|
|
enablePrefetch: true,
|
|
compressionEnabled: false,
|
|
persistToDisk: false
|
|
};
|
|
|
|
constructor(
|
|
mcpClient: MCPClientManager,
|
|
config?: Partial<TaskMasterApiConfig>
|
|
) {
|
|
this.mcpClient = mcpClient;
|
|
this.config = {
|
|
timeout: 30000,
|
|
retryAttempts: 3,
|
|
cacheDuration: 5 * 60 * 1000, // 5 minutes
|
|
...config,
|
|
cache: { ...this.defaultCacheConfig, ...config?.cache }
|
|
};
|
|
|
|
// Initialize background refresh if enabled
|
|
if (this.config.cache?.enableBackgroundRefresh) {
|
|
this.initializeBackgroundRefresh();
|
|
}
|
|
|
|
logger.log('TaskMasterApi: Initialized with enhanced caching:', {
|
|
cacheDuration: this.config.cacheDuration,
|
|
maxSize: this.config.cache?.maxSize,
|
|
backgroundRefresh: this.config.cache?.enableBackgroundRefresh,
|
|
analytics: this.config.cache?.enableAnalytics
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get tasks from Task Master using the get_tasks MCP tool
|
|
*/
|
|
async getTasks(options?: {
|
|
status?: string;
|
|
withSubtasks?: boolean;
|
|
tag?: string;
|
|
projectRoot?: string;
|
|
}): Promise<TaskMasterApiResponse<TaskMasterTask[]>> {
|
|
const startTime = Date.now();
|
|
const cacheKey = `get_tasks_${JSON.stringify(options || {})}`;
|
|
|
|
try {
|
|
// Check cache first
|
|
const cached = this.getFromCache(cacheKey);
|
|
if (cached) {
|
|
return {
|
|
success: true,
|
|
data: cached,
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
}
|
|
|
|
// Prepare MCP tool arguments
|
|
const mcpArgs: Record<string, unknown> = {
|
|
projectRoot: options?.projectRoot || this.getWorkspaceRoot(),
|
|
withSubtasks: options?.withSubtasks ?? true
|
|
};
|
|
|
|
// Add optional parameters
|
|
if (options?.status) {
|
|
mcpArgs.status = options.status;
|
|
}
|
|
if (options?.tag) {
|
|
mcpArgs.tag = options.tag;
|
|
}
|
|
|
|
logger.log('TaskMasterApi: Calling get_tasks with args:', mcpArgs);
|
|
|
|
// Call the MCP tool
|
|
const mcpResponse = await this.callMCPTool('get_tasks', mcpArgs);
|
|
|
|
// Transform the response
|
|
const transformedTasks = this.transformMCPTasksResponse(mcpResponse);
|
|
|
|
// Cache the result
|
|
this.setCache(cacheKey, transformedTasks);
|
|
|
|
return {
|
|
success: true,
|
|
data: transformedTasks,
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
} catch (error) {
|
|
logger.error('TaskMasterApi: Error getting tasks:', error);
|
|
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update task status using the set_task_status MCP tool
|
|
*/
|
|
async updateTaskStatus(
|
|
taskId: string,
|
|
status: string,
|
|
options?: {
|
|
projectRoot?: string;
|
|
}
|
|
): Promise<TaskMasterApiResponse<boolean>> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const mcpArgs: Record<string, unknown> = {
|
|
id: taskId,
|
|
status: status,
|
|
projectRoot: options?.projectRoot || this.getWorkspaceRoot()
|
|
};
|
|
|
|
logger.log('TaskMasterApi: Calling set_task_status with args:', mcpArgs);
|
|
|
|
await this.callMCPTool('set_task_status', mcpArgs);
|
|
|
|
// Clear relevant caches
|
|
this.clearCachePattern('get_tasks');
|
|
|
|
return {
|
|
success: true,
|
|
data: true,
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
} catch (error) {
|
|
logger.error('TaskMasterApi: Error updating task status:', error);
|
|
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update task content using the update_task MCP tool
|
|
*/
|
|
async updateTask(
|
|
taskId: string,
|
|
updates: {
|
|
title?: string;
|
|
description?: string;
|
|
details?: string;
|
|
priority?: 'high' | 'medium' | 'low';
|
|
testStrategy?: string;
|
|
dependencies?: string[];
|
|
},
|
|
options?: {
|
|
projectRoot?: string;
|
|
append?: boolean;
|
|
research?: boolean;
|
|
}
|
|
): Promise<TaskMasterApiResponse<boolean>> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Build the prompt for the update_task MCP tool
|
|
const updateFields: string[] = [];
|
|
|
|
if (updates.title !== undefined) {
|
|
updateFields.push(`Title: ${updates.title}`);
|
|
}
|
|
if (updates.description !== undefined) {
|
|
updateFields.push(`Description: ${updates.description}`);
|
|
}
|
|
if (updates.details !== undefined) {
|
|
updateFields.push(`Details: ${updates.details}`);
|
|
}
|
|
if (updates.priority !== undefined) {
|
|
updateFields.push(`Priority: ${updates.priority}`);
|
|
}
|
|
if (updates.testStrategy !== undefined) {
|
|
updateFields.push(`Test Strategy: ${updates.testStrategy}`);
|
|
}
|
|
if (updates.dependencies !== undefined) {
|
|
updateFields.push(`Dependencies: ${updates.dependencies.join(', ')}`);
|
|
}
|
|
|
|
const prompt = `Update task with the following changes:\n${updateFields.join('\n')}`;
|
|
|
|
const mcpArgs: Record<string, unknown> = {
|
|
id: taskId,
|
|
prompt: prompt,
|
|
projectRoot: options?.projectRoot || this.getWorkspaceRoot()
|
|
};
|
|
|
|
// Add optional parameters
|
|
if (options?.append !== undefined) {
|
|
mcpArgs.append = options.append;
|
|
}
|
|
if (options?.research !== undefined) {
|
|
mcpArgs.research = options.research;
|
|
}
|
|
|
|
logger.log('TaskMasterApi: Calling update_task with args:', mcpArgs);
|
|
|
|
await this.callMCPTool('update_task', mcpArgs);
|
|
|
|
// Clear relevant caches
|
|
this.clearCachePattern('get_tasks');
|
|
|
|
return {
|
|
success: true,
|
|
data: true,
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
} catch (error) {
|
|
logger.error('TaskMasterApi: Error updating task:', error);
|
|
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update subtask content using the update_subtask MCP tool
|
|
*/
|
|
async updateSubtask(
|
|
taskId: string,
|
|
prompt: string,
|
|
options?: {
|
|
projectRoot?: string;
|
|
research?: boolean;
|
|
}
|
|
): Promise<TaskMasterApiResponse<boolean>> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const mcpArgs: Record<string, unknown> = {
|
|
id: taskId,
|
|
prompt: prompt,
|
|
projectRoot: options?.projectRoot || this.getWorkspaceRoot()
|
|
};
|
|
|
|
// Add optional parameters
|
|
if (options?.research !== undefined) {
|
|
mcpArgs.research = options.research;
|
|
}
|
|
|
|
logger.log('TaskMasterApi: Calling update_subtask with args:', mcpArgs);
|
|
|
|
await this.callMCPTool('update_subtask', mcpArgs);
|
|
|
|
// Clear relevant caches
|
|
this.clearCachePattern('get_tasks');
|
|
|
|
return {
|
|
success: true,
|
|
data: true,
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
} catch (error) {
|
|
logger.error('TaskMasterApi: Error updating subtask:', error);
|
|
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a new subtask to an existing task using the add_subtask MCP tool
|
|
*/
|
|
async addSubtask(
|
|
parentTaskId: string,
|
|
subtaskData: {
|
|
title: string;
|
|
description?: string;
|
|
dependencies?: string[];
|
|
status?: string;
|
|
},
|
|
options?: {
|
|
projectRoot?: string;
|
|
}
|
|
): Promise<TaskMasterApiResponse<boolean>> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const mcpArgs: Record<string, unknown> = {
|
|
id: parentTaskId,
|
|
title: subtaskData.title,
|
|
projectRoot: options?.projectRoot || this.getWorkspaceRoot()
|
|
};
|
|
|
|
// Add optional parameters
|
|
if (subtaskData.description) {
|
|
mcpArgs.description = subtaskData.description;
|
|
}
|
|
if (subtaskData.dependencies && subtaskData.dependencies.length > 0) {
|
|
mcpArgs.dependencies = subtaskData.dependencies.join(',');
|
|
}
|
|
if (subtaskData.status) {
|
|
mcpArgs.status = subtaskData.status;
|
|
}
|
|
|
|
logger.log('TaskMasterApi: Calling add_subtask with args:', mcpArgs);
|
|
|
|
await this.callMCPTool('add_subtask', mcpArgs);
|
|
|
|
// Clear relevant caches to force refresh
|
|
this.clearCachePattern('get_tasks');
|
|
|
|
return {
|
|
success: true,
|
|
data: true,
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
} catch (error) {
|
|
logger.error('TaskMasterApi: Error adding subtask:', error);
|
|
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current Task Master connection status
|
|
*/
|
|
getConnectionStatus(): { isConnected: boolean; error?: string } {
|
|
const status = this.mcpClient.getStatus();
|
|
return {
|
|
isConnected: status.isRunning,
|
|
error: status.error
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Test the connection to Task Master
|
|
*/
|
|
async testConnection(): Promise<TaskMasterApiResponse<boolean>> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const isConnected = await this.mcpClient.testConnection();
|
|
|
|
return {
|
|
success: true,
|
|
data: isConnected,
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
} catch (error) {
|
|
logger.error('TaskMasterApi: Connection test failed:', error);
|
|
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : 'Connection test failed',
|
|
requestDuration: Date.now() - startTime
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all cached data
|
|
*/
|
|
clearCache(): void {
|
|
this.cache.clear();
|
|
this.resetCacheAnalytics();
|
|
}
|
|
|
|
/**
|
|
* Get cache analytics
|
|
*/
|
|
getCacheAnalytics(): CacheAnalytics {
|
|
this.updateAnalytics();
|
|
return { ...this.cacheAnalytics };
|
|
}
|
|
|
|
/**
|
|
* Initialize background refresh timer
|
|
*/
|
|
private initializeBackgroundRefresh(): void {
|
|
if (this.backgroundRefreshTimer) {
|
|
clearInterval(this.backgroundRefreshTimer);
|
|
}
|
|
|
|
const interval = this.config.cache?.refreshInterval || 5 * 60 * 1000;
|
|
this.backgroundRefreshTimer = setInterval(() => {
|
|
this.performBackgroundRefresh();
|
|
}, interval);
|
|
|
|
logger.log(
|
|
`TaskMasterApi: Background refresh initialized with ${interval}ms interval`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Perform background refresh of frequently accessed cache entries
|
|
*/
|
|
private async performBackgroundRefresh(): Promise<void> {
|
|
if (!this.config.cache?.enableBackgroundRefresh) {
|
|
return;
|
|
}
|
|
|
|
logger.log('TaskMasterApi: Starting background cache refresh');
|
|
const startTime = Date.now();
|
|
|
|
// Find frequently accessed entries that are close to expiration
|
|
const refreshCandidates = Array.from(this.cache.entries())
|
|
.filter(([key, entry]) => {
|
|
const age = Date.now() - entry.timestamp;
|
|
const isNearExpiration = age > this.config.cacheDuration * 0.7; // 70% of TTL
|
|
const isFrequentlyAccessed = entry.accessCount >= 3;
|
|
return (
|
|
isNearExpiration && isFrequentlyAccessed && key.includes('get_tasks')
|
|
);
|
|
})
|
|
.sort((a, b) => b[1].accessCount - a[1].accessCount) // Most accessed first
|
|
.slice(0, 5); // Limit to top 5 entries
|
|
|
|
let refreshedCount = 0;
|
|
for (const [key, entry] of refreshCandidates) {
|
|
try {
|
|
// Parse the cache key to extract options
|
|
const optionsMatch = key.match(/get_tasks_(.+)/);
|
|
if (optionsMatch) {
|
|
const options = JSON.parse(optionsMatch[1]);
|
|
logger.log(`TaskMasterApi: Background refreshing cache key: ${key}`);
|
|
|
|
// Perform the refresh (this will update the cache)
|
|
await this.getTasks(options);
|
|
refreshedCount++;
|
|
this.cacheAnalytics.refreshes++;
|
|
}
|
|
} catch (error) {
|
|
logger.warn(
|
|
`TaskMasterApi: Background refresh failed for key ${key}:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
logger.log(
|
|
`TaskMasterApi: Background refresh completed in ${duration}ms, refreshed ${refreshedCount} entries`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reset cache analytics
|
|
*/
|
|
private resetCacheAnalytics(): void {
|
|
this.cacheAnalytics = {
|
|
hits: 0,
|
|
misses: 0,
|
|
evictions: 0,
|
|
refreshes: 0,
|
|
totalSize: 0,
|
|
averageAccessTime: 0,
|
|
hitRate: 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update cache analytics calculations
|
|
*/
|
|
private updateAnalytics(): void {
|
|
const total = this.cacheAnalytics.hits + this.cacheAnalytics.misses;
|
|
this.cacheAnalytics.hitRate =
|
|
total > 0 ? this.cacheAnalytics.hits / total : 0;
|
|
this.cacheAnalytics.totalSize = this.cache.size;
|
|
|
|
if (this.cache.size > 0) {
|
|
const totalAccessTime = Array.from(this.cache.values()).reduce(
|
|
(sum, entry) => sum + (entry.lastAccessed - entry.timestamp),
|
|
0
|
|
);
|
|
this.cacheAnalytics.averageAccessTime = totalAccessTime / this.cache.size;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear cache entries matching a pattern
|
|
*/
|
|
private clearCachePattern(pattern: string): void {
|
|
let evictedCount = 0;
|
|
for (const key of this.cache.keys()) {
|
|
if (key.includes(pattern)) {
|
|
this.cache.delete(key);
|
|
evictedCount++;
|
|
}
|
|
}
|
|
|
|
if (evictedCount > 0) {
|
|
this.cacheAnalytics.evictions += evictedCount;
|
|
logger.log(
|
|
`TaskMasterApi: Evicted ${evictedCount} cache entries matching pattern: ${pattern}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get data from cache if not expired with analytics tracking
|
|
*/
|
|
private getFromCache(key: string): any {
|
|
const startTime = Date.now();
|
|
const cached = this.cache.get(key);
|
|
|
|
if (cached) {
|
|
const isExpired =
|
|
Date.now() - cached.timestamp >=
|
|
(cached.ttl || this.config.cacheDuration);
|
|
|
|
if (!isExpired) {
|
|
// Update access statistics
|
|
cached.accessCount++;
|
|
cached.lastAccessed = Date.now();
|
|
|
|
if (this.config.cache?.enableAnalytics) {
|
|
this.cacheAnalytics.hits++;
|
|
}
|
|
|
|
const accessTime = Date.now() - startTime;
|
|
logger.log(
|
|
`TaskMasterApi: Cache hit for ${key} (${accessTime}ms, ${cached.accessCount} accesses)`
|
|
);
|
|
return cached.data;
|
|
} else {
|
|
// Remove expired entry
|
|
this.cache.delete(key);
|
|
logger.log(`TaskMasterApi: Cache entry expired and removed: ${key}`);
|
|
}
|
|
}
|
|
|
|
if (this.config.cache?.enableAnalytics) {
|
|
this.cacheAnalytics.misses++;
|
|
}
|
|
|
|
logger.log(`TaskMasterApi: Cache miss for ${key}`);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Set data in cache with enhanced metadata and LRU eviction
|
|
*/
|
|
private setCache(
|
|
key: string,
|
|
data: any,
|
|
options?: { ttl?: number; tags?: string[] }
|
|
): void {
|
|
const now = Date.now();
|
|
const dataSize = this.estimateDataSize(data);
|
|
|
|
// Create cache entry
|
|
const entry: CacheEntry = {
|
|
data,
|
|
timestamp: now,
|
|
accessCount: 1,
|
|
lastAccessed: now,
|
|
size: dataSize,
|
|
ttl: options?.ttl,
|
|
tags: options?.tags || [key.split('_')[0]] // Default tag based on key prefix
|
|
};
|
|
|
|
// Check if we need to evict entries (LRU strategy)
|
|
const maxSize = this.config.cache?.maxSize || 100;
|
|
if (this.cache.size >= maxSize) {
|
|
this.evictLRUEntries(Math.max(1, Math.floor(maxSize * 0.1))); // Evict 10% of max size
|
|
}
|
|
|
|
this.cache.set(key, entry);
|
|
logger.log(
|
|
`TaskMasterApi: Cached data for ${key} (size: ${dataSize} bytes, TTL: ${entry.ttl || this.config.cacheDuration}ms)`
|
|
);
|
|
|
|
// Trigger prefetch if enabled
|
|
if (this.config.cache?.enablePrefetch) {
|
|
this.scheduleRelatedDataPrefetch(key, data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evict least recently used cache entries
|
|
*/
|
|
private evictLRUEntries(count: number): void {
|
|
const entries = Array.from(this.cache.entries())
|
|
.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed) // Oldest first
|
|
.slice(0, count);
|
|
|
|
for (const [key] of entries) {
|
|
this.cache.delete(key);
|
|
this.cacheAnalytics.evictions++;
|
|
}
|
|
|
|
if (entries.length > 0) {
|
|
logger.log(`TaskMasterApi: Evicted ${entries.length} LRU cache entries`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Estimate data size for cache analytics
|
|
*/
|
|
private estimateDataSize(data: any): number {
|
|
try {
|
|
return JSON.stringify(data).length * 2; // Rough estimate (2 bytes per character)
|
|
} catch {
|
|
return 1000; // Default fallback size
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule prefetch of related data
|
|
*/
|
|
private scheduleRelatedDataPrefetch(key: string, data: any): void {
|
|
// This is a simple implementation - in a more sophisticated system,
|
|
// we might prefetch related tasks, subtasks, or dependency data
|
|
if (key.includes('get_tasks') && Array.isArray(data)) {
|
|
logger.log(
|
|
`TaskMasterApi: Scheduled prefetch for ${data.length} tasks related to ${key}`
|
|
);
|
|
// Future enhancement: prefetch individual task details, related dependencies, etc.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup method to clear timers
|
|
*/
|
|
destroy(): void {
|
|
if (this.backgroundRefreshTimer) {
|
|
clearInterval(this.backgroundRefreshTimer);
|
|
this.backgroundRefreshTimer = undefined;
|
|
}
|
|
this.clearCache();
|
|
logger.log('TaskMasterApi: Destroyed and cleaned up resources');
|
|
}
|
|
|
|
/**
|
|
* Call MCP tool with retry logic
|
|
*/
|
|
private async callMCPTool(
|
|
toolName: string,
|
|
args: Record<string, unknown>
|
|
): Promise<any> {
|
|
let lastError: Error | null = null;
|
|
|
|
for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
|
|
try {
|
|
const rawResponse = await this.mcpClient.callTool(toolName, args);
|
|
logger.log(
|
|
`🔍 DEBUGGING: Raw MCP response for ${toolName}:`,
|
|
JSON.stringify(rawResponse, null, 2)
|
|
);
|
|
|
|
// Parse MCP response format: { content: [{ type: 'text', text: '{"data": {...}}' }] }
|
|
if (
|
|
rawResponse &&
|
|
rawResponse.content &&
|
|
Array.isArray(rawResponse.content) &&
|
|
rawResponse.content[0]
|
|
) {
|
|
const contentItem = rawResponse.content[0];
|
|
if (contentItem.type === 'text' && contentItem.text) {
|
|
try {
|
|
const parsedData = JSON.parse(contentItem.text);
|
|
logger.log(
|
|
`🔍 DEBUGGING: Parsed MCP data for ${toolName}:`,
|
|
parsedData
|
|
);
|
|
return parsedData;
|
|
} catch (parseError) {
|
|
logger.error(
|
|
`TaskMasterApi: Failed to parse MCP response text for ${toolName}:`,
|
|
parseError
|
|
);
|
|
logger.error(`TaskMasterApi: Raw text was:`, contentItem.text);
|
|
return rawResponse; // Fall back to original response
|
|
}
|
|
}
|
|
}
|
|
|
|
// If not in expected format, return as-is
|
|
logger.warn(
|
|
`TaskMasterApi: Unexpected MCP response format for ${toolName}, returning raw response`
|
|
);
|
|
return rawResponse;
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error : new Error('Unknown error');
|
|
logger.warn(
|
|
`TaskMasterApi: Attempt ${attempt}/${this.config.retryAttempts} failed for ${toolName}:`,
|
|
lastError.message
|
|
);
|
|
|
|
if (attempt < this.config.retryAttempts) {
|
|
// Exponential backoff
|
|
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
}
|
|
|
|
throw (
|
|
lastError ||
|
|
new Error(
|
|
`Failed to call ${toolName} after ${this.config.retryAttempts} attempts`
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Transform MCP tasks response to our internal format with comprehensive validation
|
|
*/
|
|
private transformMCPTasksResponse(mcpResponse: any): TaskMasterTask[] {
|
|
const transformStartTime = Date.now();
|
|
|
|
try {
|
|
// Validate response structure
|
|
const validationResult = this.validateMCPResponse(mcpResponse);
|
|
if (!validationResult.isValid) {
|
|
logger.warn(
|
|
'TaskMasterApi: MCP response validation failed:',
|
|
validationResult.errors
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const tasks = mcpResponse.data.tasks || [];
|
|
logger.log(
|
|
`TaskMasterApi: Transforming ${tasks.length} tasks from MCP response`
|
|
);
|
|
|
|
const transformedTasks: TaskMasterTask[] = [];
|
|
const transformationErrors: Array<{
|
|
taskId: any;
|
|
error: string;
|
|
task: any;
|
|
}> = [];
|
|
|
|
for (let i = 0; i < tasks.length; i++) {
|
|
try {
|
|
const task = tasks[i];
|
|
const transformedTask = this.transformSingleTask(task, i);
|
|
if (transformedTask) {
|
|
transformedTasks.push(transformedTask);
|
|
}
|
|
} catch (error) {
|
|
const errorMsg =
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Unknown transformation error';
|
|
transformationErrors.push({
|
|
taskId: tasks[i]?.id || `unknown_${i}`,
|
|
error: errorMsg,
|
|
task: tasks[i]
|
|
});
|
|
logger.error(
|
|
`TaskMasterApi: Failed to transform task at index ${i}:`,
|
|
errorMsg,
|
|
tasks[i]
|
|
);
|
|
}
|
|
}
|
|
|
|
// Log transformation summary
|
|
const transformDuration = Date.now() - transformStartTime;
|
|
logger.log(
|
|
`TaskMasterApi: Transformation completed in ${transformDuration}ms`,
|
|
{
|
|
totalTasks: tasks.length,
|
|
successfulTransformations: transformedTasks.length,
|
|
errors: transformationErrors.length,
|
|
errorSummary: transformationErrors.map((e) => ({
|
|
id: e.taskId,
|
|
error: e.error
|
|
}))
|
|
}
|
|
);
|
|
|
|
return transformedTasks;
|
|
} catch (error) {
|
|
logger.error(
|
|
'TaskMasterApi: Critical error during response transformation:',
|
|
error
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate MCP response structure
|
|
*/
|
|
private validateMCPResponse(mcpResponse: any): {
|
|
isValid: boolean;
|
|
errors: string[];
|
|
} {
|
|
const errors: string[] = [];
|
|
|
|
if (!mcpResponse) {
|
|
errors.push('Response is null or undefined');
|
|
return { isValid: false, errors };
|
|
}
|
|
|
|
if (typeof mcpResponse !== 'object') {
|
|
errors.push('Response is not an object');
|
|
return { isValid: false, errors };
|
|
}
|
|
|
|
if (mcpResponse.error) {
|
|
errors.push(`MCP error: ${mcpResponse.error}`);
|
|
}
|
|
|
|
if (!mcpResponse.data) {
|
|
errors.push('Response missing data property');
|
|
} else if (typeof mcpResponse.data !== 'object') {
|
|
errors.push('Response data is not an object');
|
|
}
|
|
|
|
if (mcpResponse.data && !Array.isArray(mcpResponse.data.tasks)) {
|
|
// Allow null/undefined tasks array, but not wrong type
|
|
if (
|
|
mcpResponse.data.tasks !== null &&
|
|
mcpResponse.data.tasks !== undefined
|
|
) {
|
|
errors.push('Response data.tasks is not an array');
|
|
}
|
|
}
|
|
|
|
return { isValid: errors.length === 0, errors };
|
|
}
|
|
|
|
/**
|
|
* Transform a single task with comprehensive validation
|
|
*/
|
|
private transformSingleTask(task: any, index: number): TaskMasterTask | null {
|
|
if (!task || typeof task !== 'object') {
|
|
logger.warn(
|
|
`TaskMasterApi: Task at index ${index} is not a valid object:`,
|
|
task
|
|
);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Validate required fields
|
|
const taskId = this.validateAndNormalizeId(task.id, index);
|
|
const title =
|
|
this.validateAndNormalizeString(
|
|
task.title,
|
|
'Untitled Task',
|
|
`title for task ${taskId}`
|
|
) || 'Untitled Task';
|
|
const description =
|
|
this.validateAndNormalizeString(
|
|
task.description,
|
|
'',
|
|
`description for task ${taskId}`
|
|
) || '';
|
|
|
|
// Normalize and validate status/priority
|
|
const status = this.normalizeStatus(task.status);
|
|
const priority = this.normalizePriority(task.priority);
|
|
|
|
// Handle optional fields
|
|
const details = this.validateAndNormalizeString(
|
|
task.details,
|
|
undefined,
|
|
`details for task ${taskId}`
|
|
);
|
|
const testStrategy = this.validateAndNormalizeString(
|
|
task.testStrategy,
|
|
undefined,
|
|
`testStrategy for task ${taskId}`
|
|
);
|
|
|
|
// Handle complexity score
|
|
const complexityScore =
|
|
typeof task.complexityScore === 'number'
|
|
? task.complexityScore
|
|
: undefined;
|
|
|
|
// Transform dependencies
|
|
const dependencies = this.transformDependencies(
|
|
task.dependencies,
|
|
taskId
|
|
);
|
|
|
|
// Transform subtasks
|
|
const subtasks = this.transformSubtasks(task.subtasks, taskId);
|
|
|
|
const transformedTask: TaskMasterTask = {
|
|
id: taskId,
|
|
title,
|
|
description,
|
|
status,
|
|
priority,
|
|
details,
|
|
testStrategy,
|
|
complexityScore,
|
|
dependencies,
|
|
subtasks
|
|
};
|
|
|
|
// Log successful transformation for complex tasks
|
|
if (
|
|
subtasks.length > 0 ||
|
|
dependencies.length > 0 ||
|
|
complexityScore !== undefined
|
|
) {
|
|
logger.log(
|
|
`TaskMasterApi: Successfully transformed complex task ${taskId}:`,
|
|
{
|
|
subtaskCount: subtasks.length,
|
|
dependencyCount: dependencies.length,
|
|
status,
|
|
priority,
|
|
complexityScore
|
|
}
|
|
);
|
|
}
|
|
|
|
return transformedTask;
|
|
} catch (error) {
|
|
logger.error(
|
|
`TaskMasterApi: Error transforming task at index ${index}:`,
|
|
error,
|
|
task
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate and normalize task ID
|
|
*/
|
|
private validateAndNormalizeId(id: any, fallbackIndex: number): string {
|
|
if (id === null || id === undefined) {
|
|
const generatedId = `generated_${fallbackIndex}_${Date.now()}`;
|
|
logger.warn(`TaskMasterApi: Task missing ID, generated: ${generatedId}`);
|
|
return generatedId;
|
|
}
|
|
|
|
const stringId = String(id).trim();
|
|
if (stringId === '') {
|
|
const generatedId = `empty_${fallbackIndex}_${Date.now()}`;
|
|
logger.warn(
|
|
`TaskMasterApi: Task has empty ID, generated: ${generatedId}`
|
|
);
|
|
return generatedId;
|
|
}
|
|
|
|
return stringId;
|
|
}
|
|
|
|
/**
|
|
* Validate and normalize string fields
|
|
*/
|
|
private validateAndNormalizeString(
|
|
value: any,
|
|
defaultValue: string | undefined,
|
|
fieldName: string
|
|
): string | undefined {
|
|
if (value === null || value === undefined) {
|
|
return defaultValue;
|
|
}
|
|
|
|
if (typeof value !== 'string') {
|
|
logger.warn(
|
|
`TaskMasterApi: ${fieldName} is not a string, converting:`,
|
|
value
|
|
);
|
|
return String(value).trim() || defaultValue;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (trimmed === '' && defaultValue !== undefined) {
|
|
return defaultValue;
|
|
}
|
|
|
|
return trimmed || defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Transform and validate dependencies
|
|
*/
|
|
private transformDependencies(dependencies: any, taskId: string): string[] {
|
|
if (!dependencies) {
|
|
return [];
|
|
}
|
|
|
|
if (!Array.isArray(dependencies)) {
|
|
logger.warn(
|
|
`TaskMasterApi: Dependencies for task ${taskId} is not an array:`,
|
|
dependencies
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const validDependencies: string[] = [];
|
|
for (let i = 0; i < dependencies.length; i++) {
|
|
const dep = dependencies[i];
|
|
if (dep === null || dep === undefined) {
|
|
logger.warn(
|
|
`TaskMasterApi: Null dependency at index ${i} for task ${taskId}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const stringDep = String(dep).trim();
|
|
if (stringDep === '') {
|
|
logger.warn(
|
|
`TaskMasterApi: Empty dependency at index ${i} for task ${taskId}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Check for self-dependency
|
|
if (stringDep === taskId) {
|
|
logger.warn(
|
|
`TaskMasterApi: Self-dependency detected for task ${taskId}, skipping`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
validDependencies.push(stringDep);
|
|
}
|
|
|
|
return validDependencies;
|
|
}
|
|
|
|
/**
|
|
* Transform and validate subtasks
|
|
*/
|
|
private transformSubtasks(
|
|
subtasks: any,
|
|
parentTaskId: string
|
|
): Array<{
|
|
id: number;
|
|
title: string;
|
|
description?: string;
|
|
status: string;
|
|
details?: string;
|
|
dependencies?: Array<number | string>;
|
|
}> {
|
|
if (!subtasks) {
|
|
return [];
|
|
}
|
|
|
|
if (!Array.isArray(subtasks)) {
|
|
logger.warn(
|
|
`TaskMasterApi: Subtasks for task ${parentTaskId} is not an array:`,
|
|
subtasks
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const validSubtasks = [];
|
|
for (let i = 0; i < subtasks.length; i++) {
|
|
try {
|
|
const subtask = subtasks[i];
|
|
if (!subtask || typeof subtask !== 'object') {
|
|
logger.warn(
|
|
`TaskMasterApi: Invalid subtask at index ${i} for task ${parentTaskId}:`,
|
|
subtask
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const transformedSubtask = {
|
|
id: typeof subtask.id === 'number' ? subtask.id : i + 1,
|
|
title:
|
|
this.validateAndNormalizeString(
|
|
subtask.title,
|
|
`Subtask ${i + 1}`,
|
|
`subtask title for parent ${parentTaskId}`
|
|
) || `Subtask ${i + 1}`,
|
|
description: this.validateAndNormalizeString(
|
|
subtask.description,
|
|
undefined,
|
|
`subtask description for parent ${parentTaskId}`
|
|
),
|
|
status:
|
|
this.validateAndNormalizeString(
|
|
subtask.status,
|
|
'pending',
|
|
`subtask status for parent ${parentTaskId}`
|
|
) || 'pending',
|
|
details: this.validateAndNormalizeString(
|
|
subtask.details,
|
|
undefined,
|
|
`subtask details for parent ${parentTaskId}`
|
|
),
|
|
dependencies: subtask.dependencies || []
|
|
};
|
|
|
|
validSubtasks.push(transformedSubtask);
|
|
} catch (error) {
|
|
logger.error(
|
|
`TaskMasterApi: Error transforming subtask at index ${i} for task ${parentTaskId}:`,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
return validSubtasks;
|
|
}
|
|
|
|
/**
|
|
* Normalize task status to our expected values with detailed logging
|
|
*/
|
|
private normalizeStatus(status: string): TaskMasterTask['status'] {
|
|
const original = status;
|
|
const normalized = status?.toLowerCase()?.trim() || 'pending';
|
|
|
|
const statusMap: Record<string, TaskMasterTask['status']> = {
|
|
pending: 'pending',
|
|
'in-progress': 'in-progress',
|
|
in_progress: 'in-progress',
|
|
inprogress: 'in-progress',
|
|
progress: 'in-progress',
|
|
working: 'in-progress',
|
|
active: 'in-progress',
|
|
review: 'review',
|
|
reviewing: 'review',
|
|
'in-review': 'review',
|
|
in_review: 'review',
|
|
done: 'done',
|
|
completed: 'done',
|
|
complete: 'done',
|
|
finished: 'done',
|
|
closed: 'done',
|
|
resolved: 'done',
|
|
blocked: 'deferred',
|
|
block: 'deferred',
|
|
stuck: 'deferred',
|
|
waiting: 'deferred',
|
|
cancelled: 'cancelled',
|
|
canceled: 'cancelled',
|
|
cancel: 'cancelled',
|
|
abandoned: 'cancelled',
|
|
deferred: 'deferred',
|
|
defer: 'deferred',
|
|
postponed: 'deferred',
|
|
later: 'deferred'
|
|
};
|
|
|
|
const result = statusMap[normalized] || 'pending';
|
|
|
|
if (original && original !== result) {
|
|
logger.log(
|
|
`TaskMasterApi: Normalized status '${original}' -> '${result}'`
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Normalize task priority to our expected values with detailed logging
|
|
*/
|
|
private normalizePriority(priority: string): TaskMasterTask['priority'] {
|
|
const original = priority;
|
|
const normalized = priority?.toLowerCase()?.trim() || 'medium';
|
|
|
|
let result: TaskMasterTask['priority'] = 'medium';
|
|
|
|
if (
|
|
normalized.includes('high') ||
|
|
normalized.includes('urgent') ||
|
|
normalized.includes('critical') ||
|
|
normalized.includes('important') ||
|
|
normalized === 'h' ||
|
|
normalized === '3'
|
|
) {
|
|
result = 'high';
|
|
} else if (
|
|
normalized.includes('low') ||
|
|
normalized.includes('minor') ||
|
|
normalized.includes('trivial') ||
|
|
normalized === 'l' ||
|
|
normalized === '1'
|
|
) {
|
|
result = 'low';
|
|
} else if (
|
|
normalized.includes('medium') ||
|
|
normalized.includes('normal') ||
|
|
normalized.includes('standard') ||
|
|
normalized === 'm' ||
|
|
normalized === '2'
|
|
) {
|
|
result = 'medium';
|
|
}
|
|
|
|
if (original && original !== result) {
|
|
logger.log(
|
|
`TaskMasterApi: Normalized priority '${original}' -> '${result}'`
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get workspace root path
|
|
*/
|
|
private getWorkspaceRoot(): string {
|
|
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
|
|
}
|
|
}
|