| |
| |
| |
| |
| |
|
|
| import fs from 'node:fs/promises'; |
| import path from 'node:path'; |
| import { randomBytes } from 'node:crypto'; |
| import { debugLogger } from '../utils/debugLogger.js'; |
| import { coreEvents } from '../utils/events.js'; |
| import { |
| TrackerTaskSchema, |
| TaskStatus, |
| type TrackerTask, |
| } from './trackerTypes.js'; |
| import { type z } from 'zod'; |
|
|
| export class TrackerService { |
| private readonly tasksDir: string; |
|
|
| private initialized = false; |
|
|
| constructor(readonly trackerDir: string) { |
| this.tasksDir = trackerDir; |
| } |
|
|
| private async ensureInitialized(): Promise<void> { |
| if (!this.initialized) { |
| await fs.mkdir(this.tasksDir, { recursive: true }); |
| this.initialized = true; |
| } |
| } |
|
|
| |
| |
| |
| private generateId(): string { |
| return randomBytes(3).toString('hex'); |
| } |
|
|
| |
| |
| |
| async createTask(taskData: Omit<TrackerTask, 'id'>): Promise<TrackerTask> { |
| await this.ensureInitialized(); |
| const id = this.generateId(); |
| const task: TrackerTask = { |
| ...taskData, |
| id, |
| }; |
|
|
| if (task.parentId) { |
| const parent = await this.getTask(task.parentId); |
| if (!parent) { |
| throw new Error(`Parent task with ID ${task.parentId} not found.`); |
| } |
| } |
|
|
| TrackerTaskSchema.parse(task); |
|
|
| await this.saveTask(task); |
| return task; |
| } |
|
|
| |
| |
| |
| private async readJsonFile<T>( |
| filePath: string, |
| schema: z.ZodSchema<T>, |
| ): Promise<T | null> { |
| try { |
| const content = await fs.readFile(filePath, 'utf8'); |
| const data: unknown = JSON.parse(content); |
| return schema.parse(data); |
| } catch (error) { |
| if ( |
| error && |
| typeof error === 'object' && |
| 'code' in error && |
| |
| (error as NodeJS.ErrnoException).code === 'ENOENT' |
| ) { |
| return null; |
| } |
|
|
| const fileName = path.basename(filePath); |
| debugLogger.warn(`Failed to read or parse task file ${fileName}:`, error); |
| coreEvents.emitFeedback( |
| 'warning', |
| `Task tracker encountered an issue reading ${fileName}. The data might be corrupted.`, |
| error, |
| ); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| async getTask(id: string): Promise<TrackerTask | null> { |
| await this.ensureInitialized(); |
| const taskPath = path.join(this.tasksDir, `${id}.json`); |
| return this.readJsonFile(taskPath, TrackerTaskSchema); |
| } |
|
|
| |
| |
| |
| async listTasks(): Promise<TrackerTask[]> { |
| await this.ensureInitialized(); |
| try { |
| const files = await fs.readdir(this.tasksDir); |
| const jsonFiles = files.filter((f: string) => f.endsWith('.json')); |
| const tasks = await Promise.all( |
| jsonFiles.map(async (f: string) => { |
| const taskPath = path.join(this.tasksDir, f); |
| return this.readJsonFile(taskPath, TrackerTaskSchema); |
| }), |
| ); |
| return tasks.filter((t): t is TrackerTask => t !== null); |
| } catch (error) { |
| if ( |
| error && |
| typeof error === 'object' && |
| 'code' in error && |
| error.code === 'ENOENT' |
| ) { |
| return []; |
| } |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| async updateTask( |
| id: string, |
| updates: Partial<TrackerTask>, |
| ): Promise<TrackerTask> { |
| const isClosing = updates.status === TaskStatus.CLOSED; |
| const changingDependencies = updates.dependencies !== undefined; |
|
|
| const task = await this.getTask(id); |
|
|
| if (!task) { |
| throw new Error(`Task with ID ${id} not found.`); |
| } |
|
|
| const updatedTask = { ...task, ...updates, id: task.id }; |
|
|
| if (updatedTask.parentId) { |
| const parentExists = !!(await this.getTask(updatedTask.parentId)); |
| if (!parentExists) { |
| throw new Error( |
| `Parent task with ID ${updatedTask.parentId} not found.`, |
| ); |
| } |
| } |
|
|
| if (isClosing && task.status !== TaskStatus.CLOSED) { |
| await this.validateCanClose(updatedTask); |
| } |
|
|
| if (changingDependencies) { |
| await this.validateNoCircularDependencies(updatedTask); |
| } |
|
|
| TrackerTaskSchema.parse(updatedTask); |
|
|
| await this.saveTask(updatedTask); |
| return updatedTask; |
| } |
|
|
| |
| |
| |
| private async saveTask(task: TrackerTask): Promise<void> { |
| const taskPath = path.join(this.tasksDir, `${task.id}.json`); |
| await fs.writeFile(taskPath, JSON.stringify(task, null, 2), 'utf8'); |
| } |
|
|
| |
| |
| |
| private async validateCanClose(task: TrackerTask): Promise<void> { |
| for (const depId of task.dependencies) { |
| const dep = await this.getTask(depId); |
| if (!dep) { |
| throw new Error(`Dependency ${depId} not found for task ${task.id}.`); |
| } |
| if (dep.status !== TaskStatus.CLOSED) { |
| throw new Error( |
| `Cannot close task ${task.id} because dependency ${depId} is still ${dep.status}.`, |
| ); |
| } |
| } |
| } |
|
|
| |
| |
| |
| private async validateNoCircularDependencies( |
| task: TrackerTask, |
| ): Promise<void> { |
| const visited = new Set<string>(); |
| const stack = new Set<string>(); |
| const cache = new Map<string, TrackerTask>(); |
| cache.set(task.id, task); |
|
|
| const check = async (currentId: string) => { |
| if (stack.has(currentId)) { |
| throw new Error( |
| `Circular dependency detected involving task ${currentId}.`, |
| ); |
| } |
| if (visited.has(currentId)) { |
| return; |
| } |
|
|
| visited.add(currentId); |
| stack.add(currentId); |
|
|
| let currentTask = cache.get(currentId); |
| if (!currentTask) { |
| const fetched = await this.getTask(currentId); |
| if (!fetched) { |
| throw new Error(`Dependency ${currentId} not found.`); |
| } |
| currentTask = fetched; |
| cache.set(currentId, currentTask); |
| } |
|
|
| for (const depId of currentTask.dependencies) { |
| await check(depId); |
| } |
|
|
| stack.delete(currentId); |
| }; |
|
|
| await check(task.id); |
| } |
| } |
|
|