feat(mcpd): Task lifecycle — CAS transitions, risk-scaled done gate, accept gate
The work-item spine: repository, validation, service and routes for Task, plus its verdicts and append-only transition log. Transitions are compare-and-swap. The expected current state is part of the WHERE clause and the affected-row count is the answer, so two agents racing to advance the same task cannot both win — a read-then-write would let both "succeed" and silently lose one. Losing the race is a 409 naming the state the task is actually in, not a 500, because on a shared board that is a normal outcome the caller retries. Same idiom as InferenceTaskRepository's claim. QA / Security / SRE are stages inside the task's own loop, not post-submit reviewers, so each can bounce straight back to in_progress — that is what "feedback" means here. A failed build likewise goes backward, not forward. `merged` allows only `reopened`, so a merged task cannot be quietly edited; post-merge failure is the one way back in. The gate on `done` scales with risk instead of running a fixed pipeline: trivial needs nothing, normal needs a passing QA verdict, high needs QA + Security + SRE. Forcing a one-line docs fix through security review is how a process gets routed around, so the cost has to match the risk. A FAILING verdict does not clear a gate — a recorded failure is evidence the gate was tried and did not hold. The 400 names which gates are still missing. The human accept gate is a task-level yes/no on a resolved outcome, not per-tool approval: accept fires the merge and stores the comment, reject sends it back to in_progress and REQUIRES a comment, because the comment is the direction the agent works from. GET /tasks/:id returns `legalTransitions` so no client has to hardcode the table. Tests: 25 service tests — full happy path, every stage's bounce-back, illegal and no-op transitions, the CAS race (including that no event is written when the race is lost), the complete risk matrix including failing-verdict-does-not- count, and the accept/reject gate. Local: build clean, workspace 2400 passed (+25), db 128 passed (same 4 pre-existing Secret failures), lint unchanged at 869. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -2755,9 +2755,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"118"
|
"118"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T15:28:26.687Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "130",
|
"id": "130",
|
||||||
@@ -2769,9 +2769,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"129"
|
"129"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T15:28:26.698Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "131",
|
"id": "131",
|
||||||
@@ -2783,9 +2783,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"130"
|
"130"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T15:28:26.705Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "132",
|
"id": "132",
|
||||||
@@ -3251,9 +3251,9 @@
|
|||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-08-08T10:10:39.761Z",
|
"lastModified": "2026-08-08T15:28:26.705Z",
|
||||||
"taskCount": 162,
|
"taskCount": 162,
|
||||||
"completedCount": 113,
|
"completedCount": 116,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ import { registerRevisionRoutes } from './routes/revisions.js';
|
|||||||
import { registerProposalRoutes } from './routes/proposals.js';
|
import { registerProposalRoutes } from './routes/proposals.js';
|
||||||
import { registerEpicRoutes } from './routes/epics.js';
|
import { registerEpicRoutes } from './routes/epics.js';
|
||||||
import { registerEpicTaskRoutes } from './routes/epictasks.js';
|
import { registerEpicTaskRoutes } from './routes/epictasks.js';
|
||||||
|
import { registerTaskRoutes } from './routes/tasks.js';
|
||||||
import { registerSkillRoutes } from './routes/skills.js';
|
import { registerSkillRoutes } from './routes/skills.js';
|
||||||
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
||||||
import { PromptService } from './services/prompt.service.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 { ResourceProposalService } from './services/resource-proposal.service.js';
|
||||||
import { EpicRepository } from './repositories/epic.repository.js';
|
import { EpicRepository } from './repositories/epic.repository.js';
|
||||||
import { EpicTaskRepository } from './repositories/epic-task.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 { SkillRepository } from './repositories/skill.repository.js';
|
||||||
import { EpicService } from './services/epic.service.js';
|
import { EpicService } from './services/epic.service.js';
|
||||||
import { EpicTaskService } from './services/epic-task.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 { SkillService } from './services/skill.service.js';
|
||||||
import { GitBackupService } from './services/backup/git-backup.service.js';
|
import { GitBackupService } from './services/backup/git-backup.service.js';
|
||||||
import type { BackupKind } from './services/backup/yaml-serializer.js';
|
import type { BackupKind } from './services/backup/yaml-serializer.js';
|
||||||
@@ -230,6 +233,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
// ── Agentic teams ──
|
// ── Agentic teams ──
|
||||||
'epics': 'epics',
|
'epics': 'epics',
|
||||||
'epictasks': 'epictasks',
|
'epictasks': 'epictasks',
|
||||||
|
// Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap.
|
||||||
|
'tasks': 'tasks',
|
||||||
};
|
};
|
||||||
|
|
||||||
const resource = resourceMap[segment];
|
const resource = resourceMap[segment];
|
||||||
@@ -440,6 +445,7 @@ async function main(): Promise<void> {
|
|||||||
// nameResolvers needs it for CUID→name RBAC resolution.
|
// nameResolvers needs it for CUID→name RBAC resolution.
|
||||||
const epicRepo = new EpicRepository(prisma);
|
const epicRepo = new EpicRepository(prisma);
|
||||||
const epicTaskRepo = new EpicTaskRepository(prisma);
|
const epicTaskRepo = new EpicTaskRepository(prisma);
|
||||||
|
const workTaskRepo = new TaskRepository(prisma);
|
||||||
|
|
||||||
// SecretBackend bootstrap: ensure a `plaintext` default row exists and any
|
// SecretBackend bootstrap: ensure a `plaintext` default row exists and any
|
||||||
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
||||||
@@ -458,6 +464,7 @@ async function main(): Promise<void> {
|
|||||||
agents: agentRepo,
|
agents: agentRepo,
|
||||||
epics: epicRepo,
|
epics: epicRepo,
|
||||||
epictasks: epicTaskRepo,
|
epictasks: epicTaskRepo,
|
||||||
|
tasks: workTaskRepo,
|
||||||
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
|
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
|
||||||
// match on PUT /api/v1/users/:id/password (admin reset).
|
// match on PUT /api/v1/users/:id/password (admin reset).
|
||||||
users: { findById: async (id: string) => {
|
users: { findById: async (id: string) => {
|
||||||
@@ -566,6 +573,7 @@ async function main(): Promise<void> {
|
|||||||
const epicService = new EpicService(epicRepo, projectRepo);
|
const epicService = new EpicService(epicRepo, projectRepo);
|
||||||
epicService.setRevisionService(resourceRevisionService);
|
epicService.setRevisionService(resourceRevisionService);
|
||||||
const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo);
|
const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo);
|
||||||
|
const workTaskService = new TaskService(workTaskRepo, epicTaskRepo);
|
||||||
const personalityRepo = new PersonalityRepository(prisma);
|
const personalityRepo = new PersonalityRepository(prisma);
|
||||||
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
|
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
|
||||||
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
||||||
@@ -776,6 +784,7 @@ async function main(): Promise<void> {
|
|||||||
registerSkillRoutes(app, skillService, projectRepo, agentRepo);
|
registerSkillRoutes(app, skillService, projectRepo, agentRepo);
|
||||||
registerEpicRoutes(app, epicService, projectRepo);
|
registerEpicRoutes(app, epicService, projectRepo);
|
||||||
registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo);
|
registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo);
|
||||||
|
registerTaskRoutes(app, workTaskService, epicTaskRepo);
|
||||||
|
|
||||||
// ── Git-based backup ──
|
// ── Git-based backup ──
|
||||||
const gitBackup = new GitBackupService(prisma);
|
const gitBackup = new GitBackupService(prisma);
|
||||||
|
|||||||
163
src/mcpd/src/repositories/task.repository.ts
Normal file
163
src/mcpd/src/repositories/task.repository.ts
Normal file
@@ -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<Task[]>;
|
||||||
|
findById(id: string): Promise<Task | null>;
|
||||||
|
findByName(name: string): Promise<Task | null>;
|
||||||
|
findByNameAndEpicTask(name: string, epicTaskId: string): Promise<Task | null>;
|
||||||
|
create(data: TaskCreateInput): Promise<Task>;
|
||||||
|
update(id: string, data: TaskUpdateInput): Promise<Task>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Compare-and-swap. Returns true only if this caller won the transition. */
|
||||||
|
transition(id: string, fromState: TaskState, toState: TaskState): Promise<boolean>;
|
||||||
|
|
||||||
|
appendEvent(data: EventCreateInput): Promise<TaskEvent>;
|
||||||
|
findEvents(taskId: string): Promise<TaskEvent[]>;
|
||||||
|
addVerdict(data: VerdictCreateInput): Promise<TaskVerdict>;
|
||||||
|
findVerdicts(taskId: string): Promise<TaskVerdict[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Task[]> {
|
||||||
|
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<Task | null> {
|
||||||
|
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<Task | null> {
|
||||||
|
return this.prisma.task.findFirst({ where: { name }, include: TASK_INCLUDE });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByNameAndEpicTask(name: string, epicTaskId: string): Promise<Task | null> {
|
||||||
|
return this.prisma.task.findUnique({
|
||||||
|
where: { name_epicTaskId: { name, epicTaskId } },
|
||||||
|
include: TASK_INCLUDE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: TaskCreateInput): Promise<Task> {
|
||||||
|
return this.prisma.task.create({ data, include: TASK_INCLUDE });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: TaskUpdateInput): Promise<Task> {
|
||||||
|
return this.prisma.task.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ...data, version: { increment: 1 } },
|
||||||
|
include: TASK_INCLUDE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<TaskEvent> {
|
||||||
|
return this.prisma.taskEvent.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEvents(taskId: string): Promise<TaskEvent[]> {
|
||||||
|
return this.prisma.taskEvent.findMany({ where: { taskId }, orderBy: { createdAt: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addVerdict(data: VerdictCreateInput): Promise<TaskVerdict> {
|
||||||
|
return this.prisma.taskVerdict.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findVerdicts(taskId: string): Promise<TaskVerdict[]> {
|
||||||
|
return this.prisma.taskVerdict.findMany({ where: { taskId }, orderBy: { createdAt: 'asc' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/mcpd/src/routes/tasks.ts
Normal file
108
src/mcpd/src/routes/tasks.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
const resolved: Record<string, unknown> = { ...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);
|
||||||
|
});
|
||||||
|
}
|
||||||
294
src/mcpd/src/services/task.service.ts
Normal file
294
src/mcpd/src/services/task.service.ts
Normal file
@@ -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<TaskState, readonly TaskState[]> = {
|
||||||
|
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<TaskRisk, readonly TaskState[]> = {
|
||||||
|
trivial: [],
|
||||||
|
normal: ['qa_review'],
|
||||||
|
high: ['qa_review', 'security_review', 'sre_review'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAGE_LABEL: Record<string, string> = {
|
||||||
|
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<Task[]> {
|
||||||
|
return this.repo.findAll(filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<Task> {
|
||||||
|
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<Task> {
|
||||||
|
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<Task> {
|
||||||
|
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<void> {
|
||||||
|
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<TransitionResult> {
|
||||||
|
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<void> {
|
||||||
|
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<TaskVerdict> {
|
||||||
|
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<TaskVerdict[]> {
|
||||||
|
await this.get(id);
|
||||||
|
return this.repo.findVerdicts(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEvents(id: string): Promise<TaskEvent[]> {
|
||||||
|
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<TransitionResult> {
|
||||||
|
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<TransitionResult> {
|
||||||
|
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<TransitionResult> {
|
||||||
|
return this.transition(id, { toState: 'reopened', note }, actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/mcpd/src/validation/task.schema.ts
Normal file
60
src/mcpd/src/validation/task.schema.ts
Normal file
@@ -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<typeof CreateTaskSchema>;
|
||||||
|
export type UpdateTaskInput = z.infer<typeof UpdateTaskSchema>;
|
||||||
290
src/mcpd/tests/services/task-service.test.ts
Normal file
290
src/mcpd/tests/services/task-service.test.ts
Normal file
@@ -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> = {}): 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<string, unknown>) => makeTask(d as Partial<Task>)),
|
||||||
|
update: vi.fn(async (_id: string, d: Record<string, unknown>) => {
|
||||||
|
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<string, unknown>) => ({ id: 'ev-1', ...d } as unknown as TaskEvent)),
|
||||||
|
findEvents: vi.fn(async () => []),
|
||||||
|
addVerdict: vi.fn(async (d: Record<string, unknown>) => ({ id: 'v-new', ...d } as unknown as TaskVerdict)),
|
||||||
|
findVerdicts: vi.fn(async () => verdicts),
|
||||||
|
} as unknown as ITaskRepository & Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
|
||||||
|
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<typeof vi.fn>).mockResolvedValueOnce(makeTask());
|
||||||
|
await expect(
|
||||||
|
service.create({ name: 'wire-the-thing', title: 'X', epicTaskId: 'et-1' }, 'user-1'),
|
||||||
|
).rejects.toThrow(/already exists in epictask 'chassis'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user