Files
gemini-cli/packages/core/src/services/trackerService.ts
2026-03-04 00:42:48 +00:00

254 lines
6.5 KiB
TypeScript

/**
* @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<void> {
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<TrackerTask, 'id'>): Promise<TrackerTask> {
await this.ensureInitialized();
const id = this.generateId();
const task: TrackerTask = {
...taskData,
id,
};
if (task.parentId) {
const parentList = await this.listTasks();
if (!parentList.find((t) => t.id === task.parentId)) {
throw new Error(`Parent task with ID ${task.parentId} not found.`);
}
}
TrackerTaskSchema.parse(task);
await this.saveTask(task);
return task;
}
/**
* Helper to read and validate a JSON file.
*/
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 &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(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;
}
}
/**
* Reads a task by ID.
*/
async getTask(id: string): Promise<TrackerTask | null> {
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<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;
}
}
/**
* Updates an existing task and saves it to disk.
*/
async updateTask(
id: string,
updates: Partial<TrackerTask>,
): Promise<TrackerTask> {
const isClosing = updates.status === TaskStatus.CLOSED;
const changingDependencies = updates.dependencies !== undefined;
let taskMap: Map<string, TrackerTask> | undefined;
if (isClosing || changingDependencies) {
const allTasks = await this.listTasks();
taskMap = new Map<string, TrackerTask>(allTasks.map((t) => [t.id, t]));
}
const task = taskMap ? taskMap.get(id) : 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 = taskMap
? taskMap.has(updatedTask.parentId)
: !!(await this.getTask(updatedTask.parentId));
if (!parentExists) {
throw new Error(
`Parent task with ID ${updatedTask.parentId} not found.`,
);
}
}
if (taskMap) {
if (isClosing && task.status !== TaskStatus.CLOSED) {
this.validateCanClose(updatedTask, taskMap);
}
if (changingDependencies) {
taskMap.set(updatedTask.id, updatedTask);
this.validateNoCircularDependencies(updatedTask, taskMap);
}
}
TrackerTaskSchema.parse(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 validateCanClose(
task: TrackerTask,
taskMap: Map<string, TrackerTask>,
): void {
for (const depId of task.dependencies) {
const dep = taskMap.get(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 validateNoCircularDependencies(
task: TrackerTask,
taskMap: Map<string, TrackerTask>,
): void {
const visited = new Set<string>();
const stack = new Set<string>();
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) {
throw new Error(`Dependency ${currentId} not found.`);
}
for (const depId of currentTask.dependencies) {
check(depId);
}
stack.delete(currentId);
};
check(task.id);
}
}