import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { EventEmitter } from 'events';
import { homedir } from 'os';
import { Task, TaskStatus, TaskPriority, createTask, updateTask as updateTaskData } from './schemas.js';
/**
* Storage for tasks
*/
export class TaskStorage extends EventEmitter {
private tasks: Map<string, Task> = new Map();
private filePath: string;
private isLoaded: boolean = false;
private timeouts: Map<string, NodeJS.Timeout> = new Map();
/**
* Create a new TaskStorage instance
* @param filePath Path to the storage file (optional, defaults to ~/.mcp-think-tank/tasks.jsonl)
*/
constructor(filePath?: string) {
super();
this.filePath = filePath || path.join(homedir(), '.mcp-think-tank', 'tasks.jsonl');
this.load();
}
/**
* Add a new task
* @param task Task to add
* @returns Added task
*/
add(task: Omit<Task, 'id' | 'created'>): Task {
if (!this.isLoaded) {
throw new Error('Task storage not yet loaded');
}
try {
// Create a new task with validation
const newTask = createTask({
description: task.description,
status: task.status,
priority: task.priority,
due: task.due,
tags: task.tags,
dependsOn: task.dependsOn
});
// Add to map
this.tasks.set(newTask.id, newTask);
// Save to storage
this.save();
console.error(`[DEBUG] [taskStorage] Task ${newTask.id} added`);
// Emit event
this.emit('task-added', newTask);
return newTask;
} catch (error) {
console.error(`[ERROR] [taskStorage] Failed to add task: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* Get a task by ID
* @param id Task ID
* @returns Task or undefined if not found
*/
get(id: string): Task | undefined {
return this.tasks.get(id);
}
/**
* Get all tasks
* @returns Array of tasks
*/
getAll(): Task[] {
return Array.from(this.tasks.values());
}
/**
* Update a task
* @param id Task ID
* @param update Task update
* @returns Updated task or undefined if not found
*/
update(id: string, update: Partial<Omit<Task, 'id' | 'created'>>): Task | undefined {
const task = this.tasks.get(id);
if (!task) {
return undefined;
}
try {
// Use the updateTaskData function for validation
const updatedTask = updateTaskData(task, update);
this.tasks.set(id, updatedTask);
this.save();
console.error(`[DEBUG] [taskStorage] Task ${id} updated`);
// Emit event
this.emit('task-updated', updatedTask);
return updatedTask;
} catch (error) {
console.error(`[ERROR] [taskStorage] Failed to update task ${id}: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
}
/**
* Delete a task
* @param id Task ID
* @returns True if deleted, false if not found
*/
delete(id: string): boolean {
const deleted = this.tasks.delete(id);
if (deleted) {
this.save();
console.error(`[DEBUG] [taskStorage] Task ${id} deleted`);
// Emit event
this.emit('task-deleted', id);
}
return deleted;
}
/**
* Get tasks by status
* @param status Task status
* @returns Array of tasks with the given status
*/
getByStatus(status: TaskStatus): Task[] {
return this.getAll().filter(task => task.status === status);
}
/**
* Get highest priority task with the given status
* @param status Task status
* @returns Highest priority task or undefined if none
*/
getHighestPriority(status: TaskStatus): Task | undefined {
const tasks = this.getByStatus(status);
// Define priority order
const priorityOrder = {
high: 3,
medium: 2,
low: 1
};
// Sort by priority (high to low) and creation date (oldest first)
return tasks.sort((a, b) => {
const priorityDiff = priorityOrder[b.priority || 'medium'] - priorityOrder[a.priority || 'medium'];
if (priorityDiff !== 0) return priorityDiff;
// If priority is the same, sort by creation date (oldest first)
return new Date(a.created).getTime() - new Date(b.created).getTime();
})[0];
}
/**
* Get related tasks
* @param taskId Task ID
* @returns Array of tasks that depend on or are depended on by the given task
*/
getRelatedTasks(taskId: string): Task[] {
const task = this.get(taskId);
if (!task) return [];
// Get tasks that this task depends on
const dependencies = task.dependsOn || [];
const dependencyTasks = dependencies
.map(id => this.get(id))
.filter(Boolean) as Task[];
// Get tasks that depend on this task
const dependents = this.getAll().filter(t =>
t.dependsOn && t.dependsOn.includes(taskId)
);
// Combine and remove duplicates
const relatedTasks = [...dependencyTasks, ...dependents];
const uniqueIds = new Set(relatedTasks.map(t => t.id));
return Array.from(uniqueIds).map(id => this.get(id) as Task);
}
/**
* Set a task to automatically transition to the next status after a delay
* @param taskId Task ID
* @param delay Delay in milliseconds
* @param targetStatus Target status
*/
setAutoTransition(taskId: string, delay: number, targetStatus: TaskStatus): void {
// Clear existing timeout
this.clearTimeout(taskId);
// Set new timeout
const timeout = setTimeout(() => {
this.update(taskId, { status: targetStatus });
this.timeouts.delete(taskId);
}, delay);
this.timeouts.set(taskId, timeout);
}
/**
* Clear a task auto-transition timeout
* @param taskId Task ID
*/
clearTimeout(taskId: string): void {
const timeout = this.timeouts.get(taskId);
if (timeout) {
clearTimeout(timeout);
this.timeouts.delete(taskId);
}
}
/**
* Clear all task auto-transition timeouts
*/
clearAllTimeouts(): void {
for (const [taskId, timeout] of this.timeouts.entries()) {
clearTimeout(timeout);
this.timeouts.delete(taskId);
}
}
/**
* Save tasks to storage
*/
save(): void {
// Throttle saving to prevent excessive disk I/O
if (this._saveTimeout) {
clearTimeout(this._saveTimeout);
}
this._saveTimeout = setTimeout(() => {
this.saveImmediately();
}, 1000);
}
/**
* Save tasks immediately without throttling
*/
saveImmediately(): void {
try {
// Ensure directory exists
const directory = path.dirname(this.filePath);
this.createDirectory(directory);
// Write tasks to file
const lines = Array.from(this.tasks.values()).map(task => JSON.stringify(task));
fs.writeFileSync(this.filePath, lines.join('\n'), 'utf8');
console.error(`[DEBUG] [taskStorage] Saved ${this.tasks.size} tasks to ${this.filePath}`);
} catch (error) {
console.error(`[ERROR] [taskStorage] Failed to save tasks: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create directory if it doesn't exist
* @param directory Directory path
*/
private createDirectory(directory: string): void {
try {
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
console.error(`[INFO] [taskStorage] Created directory ${directory}`);
}
} catch (error) {
console.error(`[ERROR] [taskStorage] Failed to create directory ${directory}: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Load tasks from storage
*/
private load(): void {
try {
// Check if file exists
if (!fs.existsSync(this.filePath)) {
console.error(`[INFO] [taskStorage] Task file ${this.filePath} doesn't exist yet, creating empty storage`);
this.createDirectory(path.dirname(this.filePath));
fs.writeFileSync(this.filePath, '', 'utf8');
this.isLoaded = true;
return;
}
// Read and parse tasks
const content = fs.readFileSync(this.filePath, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const task = JSON.parse(line) as Task;
this.tasks.set(task.id, task);
} catch (err) {
console.error(`[WARN] [taskStorage] Failed to parse task: ${line}, error: ${err instanceof Error ? err.message : String(err)}`);
}
}
console.error(`[INFO] [taskStorage] Loaded ${this.tasks.size} tasks from ${this.filePath}`);
this.isLoaded = true;
} catch (error) {
console.error(`[ERROR] [taskStorage] Failed to load tasks: ${error instanceof Error ? error.message : String(error)}`);
this.isLoaded = true; // Set to true to allow adding new tasks
}
}
/**
* Save timeout
*/
private _saveTimeout: NodeJS.Timeout | null = null;
/**
* Compatibility method for tests - Add a task
* @param task Task to add
* @returns Added task
*/
async addTask(task: Task): Promise<Task> {
// If task already has an ID, use it directly
if (task.id) {
this.tasks.set(task.id, task);
this.save();
return task;
} else {
return this.add(task);
}
}
/**
* Compatibility method for tests - Get all tasks
* @returns Array of tasks
*/
async getAllTasks(): Promise<Task[]> {
return this.getAll();
}
/**
* Compatibility method for tests - Update a task
* @param id Task ID
* @param update Task update
* @returns Updated task or throws if not found
*/
async updateTask(id: string, update: Partial<Task>): Promise<Task> {
const updatedTask = this.update(id, update);
if (!updatedTask) {
throw new Error(`Task with ID ${id} not found`);
}
return updatedTask;
}
/**
* Compatibility method for tests - Delete a task
* @param id Task ID
* @returns True if deleted, false if not found
*/
async deleteTask(id: string): Promise<boolean> {
return this.delete(id);
}
/**
* Compatibility method for tests - Get tasks by criteria
* @param filter Filter criteria
* @returns Array of tasks matching criteria
*/
async getTasksBy(filter: Partial<Task>): Promise<Task[]> {
return this.getAll().filter(task => {
return Object.entries(filter).every(([key, value]) =>
task[key as keyof Task] === value
);
});
}
}
// Export a singleton instance
export const taskStorage = new TaskStorage();