feat(core): implement task tracker foundation and service (#19464)

This commit is contained in:
anj-s
2026-02-25 15:17:17 -08:00
committed by GitHub
parent c9d07b62f2
commit 78dfe9dea8
5 changed files with 406 additions and 1 deletions

2
.gitignore vendored
View File

@@ -61,4 +61,4 @@ gemini-debug.log
.genkit
.gemini-clipboard/
.eslintcache
evals/logs/
evals/logs/

View File

@@ -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(

View File

@@ -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<TrackerTask, 'id'> = {
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/);
});
});

View File

@@ -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<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,
};
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 &&
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<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 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<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 !== 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<void> {
const allTasks = await this.listTasks();
const taskMap = new Map<string, TrackerTask>(
allTasks.map((t) => [t.id, t]),
);
// Ensure the current (possibly unsaved) task state is used
taskMap.set(task.id, task);
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) {
for (const depId of currentTask.dependencies) {
check(depId);
}
}
stack.delete(currentId);
};
check(task.id);
}
}

View File

@@ -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<typeof TrackerTaskSchema>;