diff --git a/.gitignore b/.gitignore index a2a6553cd3..0438549485 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,4 @@ gemini-debug.log .genkit .gemini-clipboard/ .eslintcache -evals/logs/ +evals/logs/ \ No newline at end of file diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index f66d60ef8b..e8530887b3 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -289,6 +289,10 @@ export class Storage { return path.join(this.getProjectTempDir(), 'plans'); } + getProjectTempTrackerDir(): string { + return path.join(this.getProjectTempDir(), 'tracker'); + } + getPlansDir(): string { if (this.customPlansDir) { const resolvedPath = path.resolve( diff --git a/packages/core/src/services/trackerService.test.ts b/packages/core/src/services/trackerService.test.ts new file mode 100644 index 0000000000..70a29d25af --- /dev/null +++ b/packages/core/src/services/trackerService.test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { TrackerService } from './trackerService.js'; +import { TaskStatus, TaskType, type TrackerTask } from './trackerTypes.js'; + +describe('TrackerService', () => { + let testTrackerDir: string; + let service: TrackerService; + + beforeEach(async () => { + testTrackerDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'tracker-service-test-'), + ); + service = new TrackerService(testTrackerDir); + }); + + afterEach(async () => { + await fs.rm(testTrackerDir, { recursive: true, force: true }); + }); + + it('should create a task with a generated 6-char hex ID', async () => { + const taskData: Omit = { + title: 'Test Task', + description: 'Test Description', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }; + + const task = await service.createTask(taskData); + expect(task.id).toMatch(/^[0-9a-f]{6}$/); + expect(task.title).toBe(taskData.title); + + const savedTask = await service.getTask(task.id); + expect(savedTask).toEqual(task); + }); + + it('should list all tasks', async () => { + await service.createTask({ + title: 'Task 1', + description: 'Desc 1', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + await service.createTask({ + title: 'Task 2', + description: 'Desc 2', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + + const tasks = await service.listTasks(); + expect(tasks.length).toBe(2); + expect(tasks.map((t) => t.title)).toContain('Task 1'); + expect(tasks.map((t) => t.title)).toContain('Task 2'); + }); + + it('should update a task', async () => { + const task = await service.createTask({ + title: 'Original Title', + description: 'Original Desc', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + + const updated = await service.updateTask(task.id, { + title: 'New Title', + status: TaskStatus.IN_PROGRESS, + }); + expect(updated.title).toBe('New Title'); + expect(updated.status).toBe('in_progress'); + expect(updated.description).toBe('Original Desc'); + + const retrieved = await service.getTask(task.id); + expect(retrieved).toEqual(updated); + }); + + it('should prevent closing a task if dependencies are not closed', async () => { + const dep = await service.createTask({ + title: 'Dependency', + description: 'Must be closed first', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + + const task = await service.createTask({ + title: 'Main Task', + description: 'Depends on dep', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [dep.id], + }); + + await expect( + service.updateTask(task.id, { status: TaskStatus.CLOSED }), + ).rejects.toThrow(/Cannot close task/); + + // Close dependency + await service.updateTask(dep.id, { status: TaskStatus.CLOSED }); + + // Now it should work + const updated = await service.updateTask(task.id, { + status: TaskStatus.CLOSED, + }); + expect(updated.status).toBe('closed'); + }); + + it('should detect circular dependencies', async () => { + const taskA = await service.createTask({ + title: 'Task A', + description: 'A', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + + const taskB = await service.createTask({ + title: 'Task B', + description: 'B', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [taskA.id], + }); + + // Try to make A depend on B + await expect( + service.updateTask(taskA.id, { dependencies: [taskB.id] }), + ).rejects.toThrow(/Circular dependency detected/); + }); +}); diff --git a/packages/core/src/services/trackerService.ts b/packages/core/src/services/trackerService.ts new file mode 100644 index 0000000000..3203b759e1 --- /dev/null +++ b/packages/core/src/services/trackerService.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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 { + if (!this.initialized) { + await fs.mkdir(this.tasksDir, { recursive: true }); + this.initialized = true; + } + } + + /** + * Generates a 6-character hex ID. + */ + private generateId(): string { + return randomBytes(3).toString('hex'); + } + + /** + * 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; + } + + /** + * Helper to read and validate a JSON file. + */ + private async readJsonFile( + filePath: string, + schema: z.ZodSchema, + ): Promise { + 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.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; + } + } + + /** + * Reads a task by ID. + */ + async getTask(id: string): Promise { + await this.ensureInitialized(); + const taskPath = path.join(this.tasksDir, `${id}.json`); + return this.readJsonFile(taskPath, TrackerTaskSchema); + } + + /** + * 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: 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; + } + } + + /** + * 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 === TaskStatus.CLOSED && + task.status !== TaskStatus.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 !== TaskStatus.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 allTasks = await this.listTasks(); + const taskMap = new Map( + allTasks.map((t) => [t.id, t]), + ); + // Ensure the current (possibly unsaved) task state is used + taskMap.set(task.id, task); + + const visited = new Set(); + const stack = new Set(); + + const check = (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 = taskMap.get(currentId); + if (currentTask) { + for (const depId of currentTask.dependencies) { + check(depId); + } + } + + stack.delete(currentId); + }; + + check(task.id); + } +} diff --git a/packages/core/src/services/trackerTypes.ts b/packages/core/src/services/trackerTypes.ts new file mode 100644 index 0000000000..7c48f5bcd4 --- /dev/null +++ b/packages/core/src/services/trackerTypes.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; + +export enum TaskType { + EPIC = 'epic', + TASK = 'task', + BUG = 'bug', +} +export const TaskTypeSchema = z.nativeEnum(TaskType); + +export enum TaskStatus { + OPEN = 'open', + IN_PROGRESS = 'in_progress', + BLOCKED = 'blocked', + CLOSED = 'closed', +} +export const TaskStatusSchema = z.nativeEnum(TaskStatus); + +export const TrackerTaskSchema = z.object({ + id: z.string().length(6), + title: z.string(), + description: z.string(), + type: TaskTypeSchema, + status: TaskStatusSchema, + parentId: z.string().optional(), + dependencies: z.array(z.string()), + subagentSessionId: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export type TrackerTask = z.infer;