agentic-teams: epics, tasks, git forges, agent authority #139

Merged
michal merged 36 commits from feat/agentic-teams into main 2026-10-09 14:47:34 +00:00
7 changed files with 932 additions and 8 deletions
Showing only changes of commit 05abe3522c - Show all commits

View File

@@ -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"
]

View File

@@ -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<void> {
// 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<void> {
agents: agentRepo,
epics: epicRepo,
epictasks: epicTaskRepo,
tasks: workTaskRepo,
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
// match on PUT /api/v1/users/:id/password (admin reset).
users: { findById: async (id: string) => {
@@ -566,6 +573,7 @@ async function main(): Promise<void> {
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<void> {
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);

View 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' } });
}
}

View 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);
});
}

View 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);
}
}

View 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>;

View 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'/);
});
});