From b5db5d300a76e8536ab4a98d609860ac7a778811 Mon Sep 17 00:00:00 2001 From: Anjali Sridhar Date: Wed, 18 Feb 2026 12:03:35 -0800 Subject: [PATCH] feat(core): implement task tracker foundation and service (Phase 1) --- .../core/src/services/trackerService.test.ts | 147 ++++++++++++++ packages/core/src/services/trackerService.ts | 191 ++++++++++++++++++ packages/core/src/services/trackerTypes.ts | 21 ++ 3 files changed, 359 insertions(+) create mode 100644 packages/core/src/services/trackerService.test.ts create mode 100644 packages/core/src/services/trackerService.ts create mode 100644 packages/core/src/services/trackerTypes.ts diff --git a/packages/core/src/services/trackerService.test.ts b/packages/core/src/services/trackerService.test.ts new file mode 100644 index 0000000000..136953994b --- /dev/null +++ b/packages/core/src/services/trackerService.test.ts @@ -0,0 +1,147 @@ +/** + * @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 type { TrackerTask } from './trackerTypes.js'; + +describe('TrackerService', () => { + let testRootDir: string; + let service: TrackerService; + + beforeEach(async () => { + testRootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'tracker-service-test-'), + ); + service = new TrackerService(testRootDir); + }); + + afterEach(async () => { + await fs.rm(testRootDir, { recursive: true, force: true }); + }); + + it('should initialize the tracker directory', async () => { + await service.ensureInitialized(); + const tasksDir = path.join(testRootDir, '.tracker', 'tasks'); + const stats = await fs.stat(tasksDir); + expect(stats.isDirectory()).toBe(true); + }); + + it('should create a task with a generated 6-char hex ID', async () => { + const taskData: Omit = { + title: 'Test Task', + description: 'Test Description', + type: 'task', + status: '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: 'task', + status: 'open', + dependencies: [], + }); + await service.createTask({ + title: 'Task 2', + description: 'Desc 2', + type: 'task', + status: '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: 'task', + status: 'open', + dependencies: [], + }); + + const updated = await service.updateTask(task.id, { + title: 'New Title', + status: '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: 'task', + status: 'open', + dependencies: [], + }); + + const task = await service.createTask({ + title: 'Main Task', + description: 'Depends on dep', + type: 'task', + status: 'open', + dependencies: [dep.id], + }); + + await expect( + service.updateTask(task.id, { status: 'closed' }), + ).rejects.toThrow(/Cannot close task/); + + // Close dependency + await service.updateTask(dep.id, { status: 'closed' }); + + // Now it should work + const updated = await service.updateTask(task.id, { status: 'closed' }); + expect(updated.status).toBe('closed'); + }); + + it('should detect circular dependencies', async () => { + const taskA = await service.createTask({ + title: 'Task A', + description: 'A', + type: 'task', + status: 'open', + dependencies: [], + }); + + const taskB = await service.createTask({ + title: 'Task B', + description: 'B', + type: 'task', + status: '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..f50eb711c1 --- /dev/null +++ b/packages/core/src/services/trackerService.ts @@ -0,0 +1,191 @@ +/** + * @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); + } +} diff --git a/packages/core/src/services/trackerTypes.ts b/packages/core/src/services/trackerTypes.ts new file mode 100644 index 0000000000..06509fc770 --- /dev/null +++ b/packages/core/src/services/trackerTypes.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export type TaskType = 'epic' | 'task' | 'bug'; + +export type TaskStatus = 'open' | 'in_progress' | 'blocked' | 'closed'; + +export interface TrackerTask { + id: string; + title: string; + description: string; + type: TaskType; + status: TaskStatus; + parentId?: string; + dependencies: string[]; + subagentSessionId?: string; + metadata?: Record; +}