/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import fs from 'node:fs/promises'; import path from 'node:path'; import type { TrackerTask } from './trackerTypes.js'; export class TrackerService { private readonly trackerDir: string; private readonly tasksDir: string; constructor(private readonly workspaceRoot: string) { this.trackerDir = path.join(this.workspaceRoot, '.tracker'); this.tasksDir = path.join(this.trackerDir, 'tasks'); } /** * Initializes the tracker storage if it doesn't exist. */ async ensureInitialized(): Promise { await fs.mkdir(this.tasksDir, { recursive: true }); } /** * Generates a 6-character hex ID. */ private generateId(): string { return Math.random().toString(16).substring(2, 8).padEnd(6, '0'); } /** * Creates a new task and saves it to disk. */ async createTask(taskData: Omit): Promise { await this.ensureInitialized(); const id = this.generateId(); const task: TrackerTask = { ...taskData, id, }; await this.saveTask(task); return task; } /** * Reads a task by ID. */ async getTask(id: string): Promise { const taskPath = path.join(this.tasksDir, `${id}.json`); try { const content = await fs.readFile(taskPath, 'utf8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return JSON.parse(content) as TrackerTask; } catch (error) { if ( error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' ) { return null; } throw error; } } /** * Lists all tasks in the tracker. */ async listTasks(): Promise { await this.ensureInitialized(); try { const files = await fs.readdir(this.tasksDir); const jsonFiles = files.filter((f) => f.endsWith('.json')); const tasks = await Promise.all( jsonFiles.map(async (f) => { const content = await fs.readFile( path.join(this.tasksDir, f), 'utf8', ); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return JSON.parse(content) as TrackerTask; }), ); return tasks; } catch (error) { if ( error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' ) { return []; } throw error; } } /** * Updates an existing task and saves it to disk. */ async updateTask( id: string, updates: Partial, ): Promise { const task = await this.getTask(id); if (!task) { throw new Error(`Task with ID ${id} not found.`); } const updatedTask = { ...task, ...updates }; // Validate status transition if closing if (updatedTask.status === 'closed' && task.status !== 'closed') { await this.validateCanClose(updatedTask); } // Validate circular dependencies if dependencies changed if (updates.dependencies) { await this.validateNoCircularDependencies(updatedTask); } await this.saveTask(updatedTask); return updatedTask; } /** * Saves a task to disk. */ private async saveTask(task: TrackerTask): Promise { const taskPath = path.join(this.tasksDir, `${task.id}.json`); await fs.writeFile(taskPath, JSON.stringify(task, null, 2), 'utf8'); } /** * Validates that a task can be closed (all dependencies must be closed). */ private async validateCanClose(task: TrackerTask): Promise { 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 !== 'closed') { throw new Error( `Cannot close task ${task.id} because dependency ${depId} is still ${dep.status}.`, ); } } } /** * Validates that there are no circular dependencies. */ private async validateNoCircularDependencies( task: TrackerTask, ): Promise { const visited = new Set(); const stack = new Set(); 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); const currentTask = currentId === task.id ? task : await this.getTask(currentId); if (currentTask) { for (const depId of currentTask.dependencies) { await check(depId); } } stack.delete(currentId); }; await check(task.id); } }