diff --git a/src/core/services/taskService.ts b/src/core/services/taskService.ts new file mode 100644 index 0000000..e48138e --- /dev/null +++ b/src/core/services/taskService.ts @@ -0,0 +1,79 @@ +import { join } from 'path'; +import { getEntityDir } from '../paths.js'; +import { getNextId } from '../counter.js'; +import { readEntity, writeEntity } from '../files.js'; +import { TaskSchema, type Task } from '../schema.js'; +import { Index } from '../index.js'; + +export function createTask(cwd: string, options: Partial = {}): Task { + const now = new Date().toISOString(); + const task: Task = TaskSchema.parse({ + id: getNextId(cwd, 'task'), + title: options.title ?? 'Untitled', + description: options.description ?? '', + status: 'open', + priority: options.priority ?? 'medium', + role: options.role, + assignedTo: options.assignedTo, + createdAt: now, + updatedAt: now, + }); + + const filePath = join(getEntityDir(cwd, 'tasks'), `${task.id}.md`); + writeEntity(filePath, task, `# ${task.title}\n\n${task.description}`); + + const index = new Index(cwd); + index.upsert(toIndexEntry(task, filePath)); + index.close(); + + return task; +} + +export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType { + const index = new Index(cwd); + const tasks = index.list('task', filters); + index.close(); + return tasks; +} + +export function getTask(cwd: string, id: string): { task: Task; body: string; filePath: string } { + const filePath = join(getEntityDir(cwd, 'tasks'), `${id}.md`); + const { frontmatter, body } = readEntity(filePath); + return { task: TaskSchema.parse(frontmatter), body, filePath }; +} + +export function updateTask(cwd: string, id: string, patch: Partial): Task { + const { task, body, filePath } = getTask(cwd, id); + const updated: Task = TaskSchema.parse({ ...task, ...patch, updatedAt: new Date().toISOString() }); + writeEntity(filePath, updated, body); + + const index = new Index(cwd); + index.upsert(toIndexEntry(updated, filePath)); + index.close(); + + return updated; +} + +export function claimTask(cwd: string, id: string, agentName: string): Task { + return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName }); +} + +export function doneTask(cwd: string, id: string): Task { + return updateTask(cwd, id, { status: 'done' }); +} + +function toIndexEntry(task: Task, filePath: string) { + return { + id: task.id, + type: 'task', + title: task.title, + content: `${task.description} ${task.tags.join(' ')}`, + filePath, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + status: task.status, + role: task.role, + assignedTo: task.assignedTo, + tags: JSON.stringify(task.tags), + }; +} diff --git a/tests/taskService.test.ts b/tests/taskService.test.ts new file mode 100644 index 0000000..fd13673 --- /dev/null +++ b/tests/taskService.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createTask, listTasks, getTask, claimTask, doneTask } from '../src/core/services/taskService.js'; + +describe('taskService', () => { + let cwd: string; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'ah-task-')); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('creates a task', () => { + const task = createTask(cwd, { title: 'Test', role: 'implementer' }); + expect(task.id).toBe('TSK-0001'); + expect(task.title).toBe('Test'); + expect(task.status).toBe('open'); + }); + + it('lists tasks', () => { + createTask(cwd, { title: 'A', role: 'implementer' }); + expect(listTasks(cwd)).toHaveLength(1); + }); + + it('claims and completes a task', () => { + const task = createTask(cwd, { title: 'B', role: 'implementer' }); + const claimed = claimTask(cwd, task.id, 'codex'); + expect(claimed.status).toBe('in_progress'); + expect(claimed.assignedTo).toBe('codex'); + const done = doneTask(cwd, task.id); + expect(done.status).toBe('done'); + }); +});