diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 6978ece..a0058f9 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2755,9 +2755,9 @@ "dependencies": [ "118" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T15:28:26.687Z" }, { "id": "130", @@ -2769,9 +2769,9 @@ "dependencies": [ "129" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T15:28:26.698Z" }, { "id": "131", @@ -2783,9 +2783,9 @@ "dependencies": [ "130" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T15:28:26.705Z" }, { "id": "132", @@ -3251,9 +3251,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T10:10:39.761Z", + "lastModified": "2026-08-08T15:28:26.705Z", "taskCount": 162, - "completedCount": 113, + "completedCount": 116, "tags": [ "master" ] diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 89e0f96..3d8a3c5 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -108,6 +108,7 @@ import { registerRevisionRoutes } from './routes/revisions.js'; import { registerProposalRoutes } from './routes/proposals.js'; import { registerEpicRoutes } from './routes/epics.js'; import { registerEpicTaskRoutes } from './routes/epictasks.js'; +import { registerTaskRoutes } from './routes/tasks.js'; import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; @@ -117,9 +118,11 @@ import { ResourceRevisionService } from './services/resource-revision.service.js import { ResourceProposalService } from './services/resource-proposal.service.js'; import { EpicRepository } from './repositories/epic.repository.js'; import { EpicTaskRepository } from './repositories/epic-task.repository.js'; +import { TaskRepository } from './repositories/task.repository.js'; import { SkillRepository } from './repositories/skill.repository.js'; import { EpicService } from './services/epic.service.js'; import { EpicTaskService } from './services/epic-task.service.js'; +import { TaskService } from './services/task.service.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -230,6 +233,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { // ── Agentic teams ── 'epics': 'epics', 'epictasks': 'epictasks', + // Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap. + 'tasks': 'tasks', }; const resource = resourceMap[segment]; @@ -440,6 +445,7 @@ async function main(): Promise { // nameResolvers needs it for CUID→name RBAC resolution. const epicRepo = new EpicRepository(prisma); const epicTaskRepo = new EpicTaskRepository(prisma); + const workTaskRepo = new TaskRepository(prisma); // SecretBackend bootstrap: ensure a `plaintext` default row exists and any // pre-existing `Secret` rows are pointed at it. Idempotent per run. @@ -458,6 +464,7 @@ async function main(): Promise { agents: agentRepo, epics: epicRepo, epictasks: epicTaskRepo, + tasks: workTaskRepo, // Resolve user CUID → email so name-scoped `edit:users:` bindings // match on PUT /api/v1/users/:id/password (admin reset). users: { findById: async (id: string) => { @@ -566,6 +573,7 @@ async function main(): Promise { const epicService = new EpicService(epicRepo, projectRepo); epicService.setRevisionService(resourceRevisionService); const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo); + const workTaskService = new TaskService(workTaskRepo, epicTaskRepo); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -776,6 +784,7 @@ async function main(): Promise { registerSkillRoutes(app, skillService, projectRepo, agentRepo); registerEpicRoutes(app, epicService, projectRepo); registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo); + registerTaskRoutes(app, workTaskService, epicTaskRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/repositories/task.repository.ts b/src/mcpd/src/repositories/task.repository.ts new file mode 100644 index 0000000..14dd9ea --- /dev/null +++ b/src/mcpd/src/repositories/task.repository.ts @@ -0,0 +1,163 @@ +import type { PrismaClient, Prisma, Task, TaskEvent, TaskState, TaskVerdict, TaskRisk } from '@prisma/client'; + +/** + * Task repository — work items plus their verdicts and transition log. + * + * State changes go through `transition()`, which is a compare-and-swap: the + * expected current state is part of the WHERE clause and the affected-row count + * is the result. Two agents racing to advance the same task both issue a valid + * UPDATE; exactly one matches. A read-then-write would let both "succeed" and + * silently lose one transition. Same idiom as InferenceTaskRepository's claim. + */ + +export interface TaskCreateInput { + name: string; + title: string; + epicTaskId: string; + ownerId: string; + description?: string; + repoId?: string; + assigneeId?: string; + risk?: TaskRisk; +} + +export interface TaskUpdateInput { + title?: string; + description?: string; + repoId?: string | null; + assigneeId?: string | null; + risk?: TaskRisk; + branch?: string; + headCommit?: string; + prUrl?: string; + prNumber?: number; + reviewNote?: string; +} + +export interface TaskFilters { + epicTaskId?: string; + state?: TaskState; + assigneeId?: string; +} + +export interface VerdictCreateInput { + taskId: string; + stage: TaskState; + commit: string; + passed: boolean; + summary: string; + agentId?: string; + findings?: Prisma.InputJsonValue; +} + +export interface EventCreateInput { + taskId: string; + toState: TaskState; + actorType: string; + fromState?: TaskState; + actorId?: string; + note?: string; +} + +export interface ITaskRepository { + findAll(filters?: TaskFilters): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameAndEpicTask(name: string, epicTaskId: string): Promise; + create(data: TaskCreateInput): Promise; + update(id: string, data: TaskUpdateInput): Promise; + delete(id: string): Promise; + + /** Compare-and-swap. Returns true only if this caller won the transition. */ + transition(id: string, fromState: TaskState, toState: TaskState): Promise; + + appendEvent(data: EventCreateInput): Promise; + findEvents(taskId: string): Promise; + addVerdict(data: VerdictCreateInput): Promise; + findVerdicts(taskId: string): Promise; +} + +const TASK_INCLUDE = { + epicTask: { select: { name: true, epicId: true } }, + repo: { select: { name: true } }, + assignee: { select: { name: true } }, +} as const; + +export class TaskRepository implements ITaskRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(filters: TaskFilters = {}): Promise { + return this.prisma.task.findMany({ + where: { + ...(filters.epicTaskId !== undefined ? { epicTaskId: filters.epicTaskId } : {}), + ...(filters.state !== undefined ? { state: filters.state } : {}), + ...(filters.assigneeId !== undefined ? { assigneeId: filters.assigneeId } : {}), + }, + include: TASK_INCLUDE, + orderBy: { createdAt: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.task.findUnique({ where: { id }, include: TASK_INCLUDE }); + } + + /** Names are unique per EpicTask; this is for the RBAC CUID→name resolver. */ + async findByName(name: string): Promise { + return this.prisma.task.findFirst({ where: { name }, include: TASK_INCLUDE }); + } + + async findByNameAndEpicTask(name: string, epicTaskId: string): Promise { + return this.prisma.task.findUnique({ + where: { name_epicTaskId: { name, epicTaskId } }, + include: TASK_INCLUDE, + }); + } + + async create(data: TaskCreateInput): Promise { + return this.prisma.task.create({ data, include: TASK_INCLUDE }); + } + + async update(id: string, data: TaskUpdateInput): Promise { + return this.prisma.task.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + include: TASK_INCLUDE, + }); + } + + async delete(id: string): Promise { + await this.prisma.task.delete({ where: { id } }); + } + + /** + * Advance the state only if it is still `fromState`. + * + * `updateMany` (not `update`) because we need the affected-row count rather + * than a throw: zero means someone else moved the task first, which is a + * normal race outcome the caller reports as a conflict, not an error. + */ + async transition(id: string, fromState: TaskState, toState: TaskState): Promise { + const result = await this.prisma.task.updateMany({ + where: { id, state: fromState }, + data: { state: toState, version: { increment: 1 } }, + }); + return result.count === 1; + } + + async appendEvent(data: EventCreateInput): Promise { + return this.prisma.taskEvent.create({ data }); + } + + async findEvents(taskId: string): Promise { + return this.prisma.taskEvent.findMany({ where: { taskId }, orderBy: { createdAt: 'asc' } }); + } + + async addVerdict(data: VerdictCreateInput): Promise { + return this.prisma.taskVerdict.create({ data }); + } + + async findVerdicts(taskId: string): Promise { + return this.prisma.taskVerdict.findMany({ where: { taskId }, orderBy: { createdAt: 'asc' } }); + } +} diff --git a/src/mcpd/src/routes/tasks.ts b/src/mcpd/src/routes/tasks.ts new file mode 100644 index 0000000..798c2bb --- /dev/null +++ b/src/mcpd/src/routes/tasks.ts @@ -0,0 +1,108 @@ +import type { TaskState } from '@prisma/client'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; + +import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js'; +import type { TaskService } from '../services/task.service.js'; +import { TaskService as TaskSvc } from '../services/task.service.js'; + +/** Who is driving this transition — used for the append-only event log. */ +function actorOf(request: FastifyRequest): { type: string; id?: string } { + const userId = request.userId; + return userId === undefined ? { type: 'system' } : { type: 'user', id: userId }; +} + +export function registerTaskRoutes( + app: FastifyInstance, + service: TaskService, + epicTaskRepo: IEpicTaskRepository, +): void { + // ── List / get ── + + app.get<{ Querystring: { epictask?: string; epicTaskId?: string; state?: TaskState; assignee?: string } }>( + '/api/v1/tasks', + async (request) => { + const { epictask, epicTaskId, state, assignee } = request.query; + let resolvedEpicTaskId = epicTaskId; + if (resolvedEpicTaskId === undefined && epictask !== undefined) { + const node = await epicTaskRepo.findByName(epictask); + if (node === null) { + throw Object.assign(new Error(`EpicTask not found: ${epictask}`), { statusCode: 404 }); + } + resolvedEpicTaskId = node.id; + } + return service.list({ + ...(resolvedEpicTaskId !== undefined ? { epicTaskId: resolvedEpicTaskId } : {}), + ...(state !== undefined ? { state } : {}), + ...(assignee !== undefined ? { assigneeId: assignee } : {}), + }); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/tasks/:id', async (request) => { + const task = await service.get(request.params.id); + // Surface what can happen next so a client never has to hardcode the table. + return { ...task, legalTransitions: TaskSvc.legalFrom(task.state) }; + }); + + // ── Create / update / delete ── + + app.post('/api/v1/tasks', async (request, reply) => { + const body = request.body as Record; + const resolved: Record = { ...body }; + + if (typeof body['epictask'] === 'string') { + const node = await epicTaskRepo.findByName(body['epictask']); + if (node === null) { + throw Object.assign(new Error(`EpicTask not found: ${body['epictask']}`), { statusCode: 404 }); + } + resolved['epicTaskId'] = node.id; + delete resolved['epictask']; + } + + const task = await service.create(resolved, request.userId ?? 'system'); + reply.code(201); + return task; + }); + + app.put<{ Params: { id: string } }>('/api/v1/tasks/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/tasks/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); + + // ── Lifecycle ── + + app.post<{ Params: { id: string } }>('/api/v1/tasks/:id/transition', async (request) => { + const { task, event } = await service.transition(request.params.id, request.body, actorOf(request)); + return { ...task, legalTransitions: TaskSvc.legalFrom(task.state), event }; + }); + + app.post<{ Params: { id: string } }>('/api/v1/tasks/:id/accept', async (request) => { + const { task } = await service.accept(request.params.id, request.body ?? {}, actorOf(request)); + return task; + }); + + app.post<{ Params: { id: string } }>('/api/v1/tasks/:id/reject', async (request) => { + const { task } = await service.reject(request.params.id, request.body, actorOf(request)); + return task; + }); + + // ── Verdicts + history ── + + app.post<{ Params: { id: string } }>('/api/v1/tasks/:id/verdicts', async (request, reply) => { + const verdict = await service.addVerdict(request.params.id, request.body); + reply.code(201); + return verdict; + }); + + app.get<{ Params: { id: string } }>('/api/v1/tasks/:id/verdicts', async (request) => { + return service.listVerdicts(request.params.id); + }); + + app.get<{ Params: { id: string } }>('/api/v1/tasks/:id/events', async (request) => { + return service.listEvents(request.params.id); + }); +} diff --git a/src/mcpd/src/services/task.service.ts b/src/mcpd/src/services/task.service.ts new file mode 100644 index 0000000..8fa35f9 --- /dev/null +++ b/src/mcpd/src/services/task.service.ts @@ -0,0 +1,294 @@ +import type { Prisma, Task, TaskEvent, TaskRisk, TaskState, TaskVerdict } from '@prisma/client'; + +import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js'; +import type { ITaskRepository } from '../repositories/task.repository.js'; +import { + AcceptTaskSchema, + AddVerdictSchema, + CreateTaskSchema, + RejectTaskSchema, + TransitionTaskSchema, +} from '../validation/task.schema.js'; +import { UpdateTaskSchema } from '../validation/task.schema.js'; + +import { NotFoundError } from './mcp-server.service.js'; + +/** + * Legal state transitions. + * + * QA / Security / SRE are stages INSIDE the task's own loop, not post-submit + * reviewers, which is why each of them can bounce straight back to + * `in_progress` — that is what "feedback" means here. `done` leaves the + * developer's loop; `awaiting_review` is the single human accept gate. + */ +const TRANSITIONS: Record = { + todo: ['in_progress', 'cancelled'], + in_progress: ['building', 'cancelled'], + // A failed build goes back to work rather than forward. + building: ['self_testing', 'in_progress', 'cancelled'], + self_testing: ['qa_review', 'done', 'in_progress', 'cancelled'], + qa_review: ['security_review', 'done', 'in_progress', 'cancelled'], + security_review: ['sre_review', 'done', 'in_progress', 'cancelled'], + sre_review: ['done', 'in_progress', 'cancelled'], + done: ['awaiting_review', 'in_progress', 'cancelled'], + // The human gate: accept fires the merge, reject sends it back with a comment. + awaiting_review: ['merged', 'in_progress', 'cancelled'], + // Post-merge failure reopens; the same agent picks it back up. + merged: ['reopened'], + reopened: ['in_progress', 'cancelled'], + cancelled: ['todo'], +}; + +/** + * Which review stages must have PASSED before a task may reach `done`. + * + * Verification effort scales with risk rather than running a fixed pipeline — + * forcing a one-line docs fix through security and SRE review is how a process + * gets routed around. Trivial work needs only the developer's own check. + */ +const REQUIRED_VERDICTS: Record = { + trivial: [], + normal: ['qa_review'], + high: ['qa_review', 'security_review', 'sre_review'], +}; + +const STAGE_LABEL: Record = { + qa_review: 'QA', + security_review: 'Security', + sre_review: 'SRE', +}; + +function badRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +function conflict(message: string): Error { + return Object.assign(new Error(message), { statusCode: 409 }); +} + +export interface TransitionResult { + task: Task; + event: TaskEvent; +} + +export class TaskService { + constructor( + private readonly repo: ITaskRepository, + private readonly epicTaskRepo: IEpicTaskRepository, + ) {} + + static isLegal(from: TaskState, to: TaskState): boolean { + return TRANSITIONS[from].includes(to); + } + + static legalFrom(from: TaskState): readonly TaskState[] { + return TRANSITIONS[from]; + } + + // ── CRUD ── + + async list(filters: { epicTaskId?: string; state?: TaskState; assigneeId?: string } = {}): Promise { + return this.repo.findAll(filters); + } + + async get(id: string): Promise { + const task = await this.repo.findById(id); + if (task === null) throw new NotFoundError(`Task not found: ${id}`); + return task; + } + + async create(input: unknown, ownerId: string): Promise { + const data = CreateTaskSchema.parse(input); + + const epicTask = await this.epicTaskRepo.findById(data.epicTaskId); + if (epicTask === null) throw new NotFoundError(`EpicTask not found: ${data.epicTaskId}`); + + const duplicate = await this.repo.findByNameAndEpicTask(data.name, data.epicTaskId); + if (duplicate !== null) { + throw conflict(`Task '${data.name}' already exists in epictask '${epicTask.name}'`); + } + + const task = await this.repo.create({ + name: data.name, + title: data.title, + epicTaskId: data.epicTaskId, + ownerId, + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.repoId !== undefined ? { repoId: data.repoId } : {}), + ...(data.assigneeId !== undefined ? { assigneeId: data.assigneeId } : {}), + ...(data.risk !== undefined ? { risk: data.risk as TaskRisk } : {}), + }); + + // Seed the log so the history has an origin, not a first entry from nowhere. + await this.repo.appendEvent({ + taskId: task.id, + toState: task.state, + actorType: 'user', + actorId: ownerId, + note: 'created', + }); + return task; + } + + async update(id: string, input: unknown): Promise { + const data = UpdateTaskSchema.parse(input); + await this.get(id); + // Spread-guard rather than passing the parsed object straight through: + // exactOptionalPropertyTypes distinguishes "absent" from "explicitly + // undefined", and repoId/assigneeId use null (not undefined) to unset. + return this.repo.update(id, { + ...(data.title !== undefined ? { title: data.title } : {}), + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.repoId !== undefined ? { repoId: data.repoId } : {}), + ...(data.assigneeId !== undefined ? { assigneeId: data.assigneeId } : {}), + ...(data.risk !== undefined ? { risk: data.risk as TaskRisk } : {}), + ...(data.branch !== undefined ? { branch: data.branch } : {}), + ...(data.headCommit !== undefined ? { headCommit: data.headCommit } : {}), + ...(data.prUrl !== undefined ? { prUrl: data.prUrl } : {}), + ...(data.prNumber !== undefined ? { prNumber: data.prNumber } : {}), + }); + } + + async delete(id: string): Promise { + await this.get(id); + await this.repo.delete(id); + } + + // ── Lifecycle ── + + /** + * Move a task to `toState`. + * + * Guarded twice: the transition must be legal for the CURRENT state, and the + * write is a compare-and-swap so a concurrent mover cannot be silently + * clobbered. Losing the CAS is a 409, not a 500 — it means the task moved + * under us, which is a normal outcome when several agents share a board. + */ + async transition( + id: string, + input: unknown, + actor: { type: string; id?: string }, + ): Promise { + const { toState, note } = TransitionTaskSchema.parse(input); + const task = await this.get(id); + const from = task.state; + + if (from === toState) { + throw badRequest(`Task is already in state '${toState}'`); + } + if (!TaskService.isLegal(from, toState)) { + throw badRequest( + `Illegal transition ${from} → ${toState}. Legal from '${from}': ` + + `${TRANSITIONS[from].join(', ') || '(terminal)'}`, + ); + } + + if (toState === 'done') { + await this.assertVerificationSatisfied(task); + } + + const won = await this.repo.transition(id, from, toState); + if (!won) { + const current = await this.repo.findById(id); + throw conflict( + `Task moved concurrently: expected '${from}', now '${current?.state ?? 'unknown'}'. Re-read and retry.`, + ); + } + + const event = await this.repo.appendEvent({ + taskId: id, + fromState: from, + toState, + actorType: actor.type, + ...(actor.id !== undefined ? { actorId: actor.id } : {}), + ...(note !== undefined ? { note } : {}), + }); + + return { task: await this.get(id), event }; + } + + /** + * Enforce the risk-scaled gate on `done`. A stage counts as satisfied only by + * a PASSING verdict — a recorded failure is evidence the gate was tried and + * did not hold, not evidence it was cleared. + */ + private async assertVerificationSatisfied(task: Task): Promise { + const required = REQUIRED_VERDICTS[task.risk]; + if (required.length === 0) return; + + const verdicts = await this.repo.findVerdicts(task.id); + const passed = new Set(verdicts.filter((v) => v.passed).map((v) => v.stage)); + const missing = required.filter((stage) => !passed.has(stage)); + + if (missing.length > 0) { + const labels = missing.map((s) => STAGE_LABEL[s] ?? s).join(', '); + throw badRequest( + `Cannot mark done: risk '${task.risk}' requires a passing verdict from ${labels}. ` + + 'Record the verdict, or lower the task risk if the gate genuinely does not apply.', + ); + } + } + + /** Record a stage verdict, pinned to the commit it judged. */ + async addVerdict(id: string, input: unknown): Promise { + const data = AddVerdictSchema.parse(input); + await this.get(id); + + return this.repo.addVerdict({ + taskId: id, + stage: data.stage as TaskState, + commit: data.commit, + passed: data.passed, + summary: data.summary, + ...(data.agentId !== undefined ? { agentId: data.agentId } : {}), + ...(data.findings !== undefined + ? { findings: data.findings as unknown as Prisma.InputJsonValue } + : {}), + }); + } + + async listVerdicts(id: string): Promise { + await this.get(id); + return this.repo.findVerdicts(id); + } + + async listEvents(id: string): Promise { + await this.get(id); + return this.repo.findEvents(id); + } + + // ── The human accept gate ── + + /** + * Accept: awaiting_review → merged. The caller's comment is stored on the + * task, so the decision and its reason live together. + * + * This is a task-level yes/no on a resolved outcome, NOT a per-tool approval + * — the agent has already acted autonomously up to this point. + */ + async accept(id: string, input: unknown, actor: { type: string; id?: string }): Promise { + const { note } = AcceptTaskSchema.parse(input ?? {}); + const task = await this.get(id); + if (task.state !== 'awaiting_review') { + throw badRequest(`Only a task in 'awaiting_review' can be accepted (this one is '${task.state}')`); + } + if (note !== undefined) await this.repo.update(id, { reviewNote: note }); + return this.transition(id, { toState: 'merged', note: note ?? 'accepted' }, actor); + } + + /** Reject: back to in_progress. The comment is mandatory — that is the direction. */ + async reject(id: string, input: unknown, actor: { type: string; id?: string }): Promise { + const { note } = RejectTaskSchema.parse(input); + const task = await this.get(id); + if (task.state !== 'awaiting_review') { + throw badRequest(`Only a task in 'awaiting_review' can be rejected (this one is '${task.state}')`); + } + await this.repo.update(id, { reviewNote: note }); + return this.transition(id, { toState: 'in_progress', note }, actor); + } + + /** Post-merge failure: merged → reopened, driven by the git provider webhook. */ + async reopen(id: string, note: string, actor: { type: string; id?: string }): Promise { + return this.transition(id, { toState: 'reopened', note }, actor); + } +} diff --git a/src/mcpd/src/validation/task.schema.ts b/src/mcpd/src/validation/task.schema.ts new file mode 100644 index 0000000..8969c3c --- /dev/null +++ b/src/mcpd/src/validation/task.schema.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +const NAME_RE = /^[a-z0-9-]+$/; + +export const TASK_STATES = [ + 'todo', 'in_progress', 'building', 'self_testing', 'qa_review', + 'security_review', 'sre_review', 'done', 'awaiting_review', 'merged', + 'reopened', 'cancelled', +] as const; + +/** The three stages that produce a verdict. */ +export const REVIEW_STAGES = ['qa_review', 'security_review', 'sre_review'] as const; + +export const CreateTaskSchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + title: z.string().min(1).max(300), + epicTaskId: z.string().min(1), + description: z.string().max(20_000).optional(), + repoId: z.string().min(1).optional(), + assigneeId: z.string().min(1).optional(), + risk: z.enum(['trivial', 'normal', 'high']).optional(), +}); + +export const UpdateTaskSchema = z.object({ + title: z.string().min(1).max(300).optional(), + description: z.string().max(20_000).optional(), + repoId: z.string().min(1).nullable().optional(), + assigneeId: z.string().min(1).nullable().optional(), + risk: z.enum(['trivial', 'normal', 'high']).optional(), + // Git linkage, written by the agent when it pushes. + branch: z.string().min(1).max(300).optional(), + headCommit: z.string().min(1).max(100).optional(), + prUrl: z.string().url().optional(), + prNumber: z.number().int().positive().optional(), +}); + +export const TransitionTaskSchema = z.object({ + toState: z.enum(TASK_STATES), + note: z.string().max(2000).optional(), +}); + +export const AddVerdictSchema = z.object({ + stage: z.enum(REVIEW_STAGES), + commit: z.string().min(1).max(100), + passed: z.boolean(), + summary: z.string().min(1).max(20_000), + agentId: z.string().min(1).optional(), + findings: z.array(z.object({ + severity: z.enum(['info', 'minor', 'major', 'critical']), + message: z.string().min(1).max(2000), + path: z.string().max(500).optional(), + line: z.number().int().nonnegative().optional(), + })).max(500).optional(), +}); + +export const AcceptTaskSchema = z.object({ note: z.string().max(2000).optional() }); +export const RejectTaskSchema = z.object({ note: z.string().min(1).max(2000) }); + +export type CreateTaskInput = z.infer; +export type UpdateTaskInput = z.infer; diff --git a/src/mcpd/tests/services/task-service.test.ts b/src/mcpd/tests/services/task-service.test.ts new file mode 100644 index 0000000..419c0b8 --- /dev/null +++ b/src/mcpd/tests/services/task-service.test.ts @@ -0,0 +1,290 @@ +import type { EpicTask, Task, TaskEvent, TaskState, TaskVerdict } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { IEpicTaskRepository } from '../../src/repositories/epic-task.repository.js'; +import type { ITaskRepository } from '../../src/repositories/task.repository.js'; +import { TaskService } from '../../src/services/task.service.js'; + +function makeTask(overrides: Partial = {}): Task { + return { + id: 'task-1', + name: 'wire-the-thing', + title: 'Wire the thing', + description: '', + epicTaskId: 'et-1', + repoId: null, + assigneeId: null, + state: 'todo', + risk: 'normal', + branch: null, + headCommit: null, + prUrl: null, + prNumber: null, + reviewNote: null, + ownerId: 'user-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Task; +} + +const verdict = (stage: TaskState, passed: boolean): TaskVerdict => + ({ id: `v-${stage}`, taskId: 'task-1', stage, agentId: null, commit: '3f2ab', passed, + summary: '', findings: [], createdAt: new Date() } as TaskVerdict); + +function mocks(task: Task | null = makeTask(), verdicts: TaskVerdict[] = []) { + let current = task; + /** When true, the CAS loses — simulating a concurrent mover. */ + let loseRace = false; + + const repo = { + findAll: vi.fn(async () => (current ? [current] : [])), + findById: vi.fn(async () => current), + findByName: vi.fn(async () => current), + findByNameAndEpicTask: vi.fn(async () => null), + create: vi.fn(async (d: Record) => makeTask(d as Partial)), + update: vi.fn(async (_id: string, d: Record) => { + current = { ...(current as Task), ...d } as Task; + return current; + }), + delete: vi.fn(async () => {}), + transition: vi.fn(async (_id: string, from: TaskState, to: TaskState) => { + if (loseRace) { current = { ...(current as Task), state: 'cancelled' } as Task; return false; } + if ((current as Task).state !== from) return false; + current = { ...(current as Task), state: to } as Task; + return true; + }), + appendEvent: vi.fn(async (d: Record) => ({ id: 'ev-1', ...d } as unknown as TaskEvent)), + findEvents: vi.fn(async () => []), + addVerdict: vi.fn(async (d: Record) => ({ id: 'v-new', ...d } as unknown as TaskVerdict)), + findVerdicts: vi.fn(async () => verdicts), + } as unknown as ITaskRepository & Record>; + + const epicTaskRepo = { + findById: vi.fn(async () => ({ id: 'et-1', name: 'chassis' } as EpicTask)), + findByName: vi.fn(async () => ({ id: 'et-1', name: 'chassis' } as EpicTask)), + } as unknown as IEpicTaskRepository; + + return { + repo, epicTaskRepo, + service: new TaskService(repo, epicTaskRepo), + forceRace: () => { loseRace = true; }, + }; +} + +const ACTOR = { type: 'user', id: 'user-1' }; + +describe('TaskService transition table', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('walks the whole happy path for a trivial task', async () => { + const { service } = mocks(makeTask({ risk: 'trivial' })); + for (const to of ['in_progress', 'building', 'self_testing', 'done', 'awaiting_review', 'merged'] as const) { + const { task } = await service.transition('task-1', { toState: to }, ACTOR); + expect(task.state).toBe(to); + } + }); + + it('lets QA, Security and SRE bounce a task back to in_progress', async () => { + // They are stages inside the loop, not post-submit reviewers — feedback + // means the task goes back to work. + for (const stage of ['qa_review', 'security_review', 'sre_review'] as const) { + const { service } = mocks(makeTask({ state: stage })); + const { task } = await service.transition('task-1', { toState: 'in_progress' }, ACTOR); + expect(task.state).toBe('in_progress'); + } + }); + + it('sends a failed build back to work rather than forward', async () => { + const { service } = mocks(makeTask({ state: 'building' })); + const { task } = await service.transition('task-1', { toState: 'in_progress' }, ACTOR); + expect(task.state).toBe('in_progress'); + }); + + it('rejects an illegal jump and lists what IS legal', async () => { + const { service } = mocks(makeTask({ state: 'todo' })); + await expect(service.transition('task-1', { toState: 'merged' }, ACTOR)).rejects.toThrow( + /Illegal transition todo → merged.*in_progress, cancelled/s, + ); + }); + + it('rejects a no-op transition', async () => { + const { service } = mocks(makeTask({ state: 'todo' })); + await expect(service.transition('task-1', { toState: 'todo' }, ACTOR)).rejects.toThrow( + /already in state 'todo'/, + ); + }); + + it('only allows reopened out of merged — a merged task is not editable', async () => { + expect(TaskService.legalFrom('merged')).toEqual(['reopened']); + const { service } = mocks(makeTask({ state: 'merged' })); + await expect(service.transition('task-1', { toState: 'in_progress' }, ACTOR)).rejects.toThrow( + /Illegal transition/, + ); + const { task } = await service.transition('task-1', { toState: 'reopened' }, ACTOR); + expect(task.state).toBe('reopened'); + }); + + it('appends an event for every transition', async () => { + const { service, repo } = mocks(makeTask()); + await service.transition('task-1', { toState: 'in_progress', note: 'picked up' }, ACTOR); + expect(repo.appendEvent).toHaveBeenCalledWith( + expect.objectContaining({ fromState: 'todo', toState: 'in_progress', actorType: 'user', note: 'picked up' }), + ); + }); +}); + +describe('TaskService compare-and-swap', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('409s when another mover won the race, and reports the current state', async () => { + const { service, forceRace } = mocks(makeTask({ state: 'todo' })); + forceRace(); + await expect(service.transition('task-1', { toState: 'in_progress' }, ACTOR)).rejects.toThrow( + /moved concurrently: expected 'todo', now 'cancelled'/, + ); + }); + + it('passes the expected state to the CAS so the DB does the arbitration', async () => { + const { service, repo } = mocks(makeTask({ state: 'todo' })); + await service.transition('task-1', { toState: 'in_progress' }, ACTOR); + expect(repo.transition).toHaveBeenCalledWith('task-1', 'todo', 'in_progress'); + }); + + it('writes no event when the CAS is lost', async () => { + const { service, repo, forceRace } = mocks(makeTask({ state: 'todo' })); + forceRace(); + await service.transition('task-1', { toState: 'in_progress' }, ACTOR).catch(() => {}); + expect(repo.appendEvent).not.toHaveBeenCalled(); + }); +}); + +describe('TaskService risk-scaled done gate', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('lets trivial work reach done with no verdicts at all', async () => { + const { service } = mocks(makeTask({ state: 'self_testing', risk: 'trivial' }), []); + const { task } = await service.transition('task-1', { toState: 'done' }, ACTOR); + expect(task.state).toBe('done'); + }); + + it('blocks normal work without a passing QA verdict', async () => { + const { service } = mocks(makeTask({ state: 'self_testing', risk: 'normal' }), []); + await expect(service.transition('task-1', { toState: 'done' }, ACTOR)).rejects.toThrow( + /requires a passing verdict from QA/, + ); + }); + + it('does not accept a FAILING verdict as clearing the gate', async () => { + // A recorded failure is evidence the gate was tried and did not hold. + const { service } = mocks( + makeTask({ state: 'qa_review', risk: 'normal' }), + [verdict('qa_review', false)], + ); + await expect(service.transition('task-1', { toState: 'done' }, ACTOR)).rejects.toThrow( + /requires a passing verdict from QA/, + ); + }); + + it('allows normal work through once QA passes', async () => { + const { service } = mocks( + makeTask({ state: 'qa_review', risk: 'normal' }), + [verdict('qa_review', true)], + ); + const { task } = await service.transition('task-1', { toState: 'done' }, ACTOR); + expect(task.state).toBe('done'); + }); + + it('requires all three gates for high-risk work, and names the missing ones', async () => { + const { service } = mocks( + makeTask({ state: 'sre_review', risk: 'high' }), + [verdict('qa_review', true)], + ); + await expect(service.transition('task-1', { toState: 'done' }, ACTOR)).rejects.toThrow( + /Security, SRE/, + ); + }); + + it('allows high-risk work once every gate passes', async () => { + const { service } = mocks( + makeTask({ state: 'sre_review', risk: 'high' }), + [verdict('qa_review', true), verdict('security_review', true), verdict('sre_review', true)], + ); + const { task } = await service.transition('task-1', { toState: 'done' }, ACTOR); + expect(task.state).toBe('done'); + }); +}); + +describe('TaskService accept / reject gate', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('accepts from awaiting_review and stores the comment', async () => { + const { service, repo } = mocks(makeTask({ state: 'awaiting_review' })); + const { task } = await service.accept('task-1', { note: 'ship it' }, ACTOR); + expect(task.state).toBe('merged'); + expect(repo.update).toHaveBeenCalledWith('task-1', { reviewNote: 'ship it' }); + }); + + it('accepts with no comment', async () => { + const { service } = mocks(makeTask({ state: 'awaiting_review' })); + const { task } = await service.accept('task-1', {}, ACTOR); + expect(task.state).toBe('merged'); + }); + + it('refuses to accept a task that is not at the gate', async () => { + const { service } = mocks(makeTask({ state: 'in_progress' })); + await expect(service.accept('task-1', {}, ACTOR)).rejects.toThrow(/Only a task in 'awaiting_review'/); + }); + + it('rejects back to in_progress and requires a comment', async () => { + const { service, repo } = mocks(makeTask({ state: 'awaiting_review' })); + const { task } = await service.reject('task-1', { note: 'missing tests on the error path' }, ACTOR); + expect(task.state).toBe('in_progress'); + expect(repo.update).toHaveBeenCalledWith('task-1', { reviewNote: 'missing tests on the error path' }); + }); + + it('will not reject without a comment — the comment IS the direction', async () => { + const { service } = mocks(makeTask({ state: 'awaiting_review' })); + await expect(service.reject('task-1', {}, ACTOR)).rejects.toThrow(); + await expect(service.reject('task-1', { note: '' }, ACTOR)).rejects.toThrow(); + }); +}); + +describe('TaskService verdicts and creation', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('pins a verdict to the commit it judged', async () => { + const { service, repo } = mocks(); + await service.addVerdict('task-1', { + stage: 'qa_review', commit: '3f2ab19', passed: false, summary: 'no test for 500', + findings: [{ severity: 'major', message: 'error path uncovered', path: 'src/a.ts', line: 12 }], + }); + expect(repo.addVerdict).toHaveBeenCalledWith( + expect.objectContaining({ taskId: 'task-1', stage: 'qa_review', commit: '3f2ab19', passed: false }), + ); + }); + + it('rejects a verdict for a non-review stage', async () => { + const { service } = mocks(); + await expect( + service.addVerdict('task-1', { stage: 'merged', commit: 'a', passed: true, summary: 's' }), + ).rejects.toThrow(); + }); + + it('seeds the event log on create so history has an origin', async () => { + const { service, repo } = mocks(null); + await service.create({ name: 'x', title: 'X', epicTaskId: 'et-1' }, 'user-1'); + expect(repo.appendEvent).toHaveBeenCalledWith( + expect.objectContaining({ toState: 'todo', actorType: 'user', note: 'created' }), + ); + }); + + it('409s on a duplicate name within the epictask', async () => { + const { service, repo } = mocks(null); + (repo.findByNameAndEpicTask as ReturnType).mockResolvedValueOnce(makeTask()); + await expect( + service.create({ name: 'wire-the-thing', title: 'X', epicTaskId: 'et-1' }, 'user-1'), + ).rejects.toThrow(/already exists in epictask 'chassis'/); + }); +});