mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-09 00:16:57 -07:00
feat(core): implement task tracker foundation and service (Phase 1)
This commit is contained in:
@@ -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<TrackerTask, 'id'> = {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<TrackerTask, 'id'>): Promise<TrackerTask> {
|
||||
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<TrackerTask | null> {
|
||||
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<TrackerTask[]> {
|
||||
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<TrackerTask>,
|
||||
): Promise<TrackerTask> {
|
||||
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<void> {
|
||||
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<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 !== '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<void> {
|
||||
const visited = new Set<string>();
|
||||
const stack = new Set<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user