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
9 changed files with 1075 additions and 11 deletions
Showing only changes of commit 909506317e - Show all commits

View File

@@ -2669,9 +2669,9 @@
"dependencies": [
"117"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T10:05:28.155Z"
},
{
"id": "124",
@@ -2683,9 +2683,9 @@
"dependencies": [
"123"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T10:05:28.165Z"
},
{
"id": "125",
@@ -2697,9 +2697,9 @@
"dependencies": [
"117"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T10:05:28.172Z"
},
{
"id": "126",
@@ -2712,9 +2712,9 @@
"124",
"125"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T10:05:28.178Z"
},
{
"id": "127",
@@ -3251,9 +3251,9 @@
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-08-08T09:36:21.139Z",
"lastModified": "2026-08-08T10:05:28.178Z",
"taskCount": 162,
"completedCount": 107,
"completedCount": 111,
"tags": [
"master"
]

View File

@@ -106,6 +106,7 @@ import {
import { registerPromptRoutes } from './routes/prompts.js';
import { registerRevisionRoutes } from './routes/revisions.js';
import { registerProposalRoutes } from './routes/proposals.js';
import { registerEpicRoutes } from './routes/epics.js';
import { registerSkillRoutes } from './routes/skills.js';
import { registerGitBackupRoutes } from './routes/git-backup.js';
import { PromptService } from './services/prompt.service.js';
@@ -113,7 +114,9 @@ import { ResourceRevisionRepository } from './repositories/resource-revision.rep
import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js';
import { ResourceRevisionService } from './services/resource-revision.service.js';
import { ResourceProposalService } from './services/resource-proposal.service.js';
import { EpicRepository } from './repositories/epic.repository.js';
import { SkillRepository } from './repositories/skill.repository.js';
import { EpicService } from './services/epic.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';
@@ -221,6 +224,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
// Personalities inherit the agent's RBAC: managing a personality
// requires view/edit/create/delete on the `agents` resource.
'personalities': 'agents',
// ── Agentic teams ──
'epics': 'epics',
};
const resource = resourceMap[segment];
@@ -427,6 +432,9 @@ async function main(): Promise<void> {
const userRepo = new UserRepository(prisma);
const groupRepo = new GroupRepository(prisma);
const mcpTokenRepo = new McpTokenRepository(prisma);
// Declared here (not with the other agentic-teams wiring below) because
// nameResolvers needs it for CUID→name RBAC resolution.
const epicRepo = new EpicRepository(prisma);
// SecretBackend bootstrap: ensure a `plaintext` default row exists and any
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
@@ -443,6 +451,7 @@ async function main(): Promise<void> {
mcptokens: mcpTokenRepo,
llms: llmRepo,
agents: agentRepo,
epics: epicRepo,
// 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) => {
@@ -545,6 +554,11 @@ async function main(): Promise<void> {
const skillService = new SkillService(skillRepo, projectRepo, agentRepo);
skillService.setRevisionService(resourceRevisionService);
skillService.setProposalService(resourceProposalService);
// Agentic teams: the Epic is the versioned vision every task walks up to.
// Revision-tracked so scope creep is as visible as under-delivery.
const epicService = new EpicService(epicRepo, projectRepo);
epicService.setRevisionService(resourceRevisionService);
const personalityRepo = new PersonalityRepository(prisma);
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
@@ -753,6 +767,7 @@ async function main(): Promise<void> {
registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService });
registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo });
registerSkillRoutes(app, skillService, projectRepo, agentRepo);
registerEpicRoutes(app, epicService, projectRepo);
// ── Git-based backup ──
const gitBackup = new GitBackupService(prisma);

View File

@@ -0,0 +1,91 @@
import type { PrismaClient, Prisma, Epic, EpicStatus } from '@prisma/client';
/**
* Epic repository. Unlike Prompt/Skill, an Epic is always project-scoped —
* there is no global epic — so the nullable-FK compound-key workaround does not
* apply here and `name_projectId` can be used directly.
*/
export interface EpicCreateInput {
name: string;
content: string;
description?: string;
doneCriteria?: Prisma.InputJsonValue;
acceptedTradeoffs?: Prisma.InputJsonValue;
projectId: string;
ownerId: string;
semver?: string;
}
export interface EpicUpdateInput {
content?: string;
description?: string;
doneCriteria?: Prisma.InputJsonValue;
acceptedTradeoffs?: Prisma.InputJsonValue;
status?: EpicStatus;
semver?: string;
currentRevisionId?: string | null;
}
export interface IEpicRepository {
findAll(projectId?: string): Promise<Epic[]>;
findById(id: string): Promise<Epic | null>;
findByName(name: string): Promise<Epic | null>;
findByNameAndProject(name: string, projectId: string): Promise<Epic | null>;
create(data: EpicCreateInput): Promise<Epic>;
update(id: string, data: EpicUpdateInput): Promise<Epic>;
delete(id: string): Promise<void>;
}
const EPIC_INCLUDE = { project: { select: { name: true } } } as const;
export class EpicRepository implements IEpicRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(projectId?: string): Promise<Epic[]> {
return this.prisma.epic.findMany({
...(projectId !== undefined ? { where: { projectId } } : {}),
include: EPIC_INCLUDE,
orderBy: { name: 'asc' },
});
}
async findById(id: string): Promise<Epic | null> {
return this.prisma.epic.findUnique({ where: { id }, include: EPIC_INCLUDE });
}
/**
* Resolve by bare name. Epic names are unique per project, not globally, so
* this returns the first match by name — used by the RBAC CUID→name resolver,
* which only needs *a* name for the binding comparison. Callers that care
* about a specific project must use findByNameAndProject.
*/
async findByName(name: string): Promise<Epic | null> {
return this.prisma.epic.findFirst({ where: { name }, include: EPIC_INCLUDE });
}
async findByNameAndProject(name: string, projectId: string): Promise<Epic | null> {
return this.prisma.epic.findUnique({
where: { name_projectId: { name, projectId } },
include: EPIC_INCLUDE,
});
}
async create(data: EpicCreateInput): Promise<Epic> {
return this.prisma.epic.create({ data, include: EPIC_INCLUDE });
}
async update(id: string, data: EpicUpdateInput): Promise<Epic> {
// Optimistic-concurrency counter is bumped in the repository, unconditionally
// and on every write — same convention as AgentRepository.
return this.prisma.epic.update({
where: { id },
data: { ...data, version: { increment: 1 } },
include: EPIC_INCLUDE,
});
}
async delete(id: string): Promise<void> {
await this.prisma.epic.delete({ where: { id } });
}
}

View File

@@ -12,7 +12,7 @@ import type { PrismaClient, Prisma, ResourceRevision } from '@prisma/client';
* service layer to keep them in sync with real IDs.
*/
export type ResourceType = 'prompt' | 'skill';
export type ResourceType = 'prompt' | 'skill' | 'epic';
export interface CreateRevisionInput {
resourceType: ResourceType;

View File

@@ -0,0 +1,107 @@
import type { Epic } from '@prisma/client';
import type { FastifyInstance } from 'fastify';
import type { IProjectRepository } from '../repositories/project.repository.js';
import type { EpicService } from '../services/epic.service.js';
import { EpicService as EpicSvc } from '../services/epic.service.js';
/** Attach computed completeness so clients never re-derive the rule. */
function withCompleteness(epic: Epic): Epic & { completeness: ReturnType<typeof EpicSvc.completeness> } {
return { ...epic, completeness: EpicSvc.completeness(epic) };
}
export function registerEpicRoutes(
app: FastifyInstance,
service: EpicService,
projectRepo: IProjectRepository,
): void {
// ── List ──
app.get<{ Querystring: { project?: string; projectId?: string } }>(
'/api/v1/epics',
async (request) => {
const { project, projectId } = request.query;
let epics: Epic[];
if (project !== undefined) {
const proj = await projectRepo.findByName(project);
if (proj === null) {
throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 });
}
epics = await service.listEpics(proj.id);
} else if (projectId !== undefined) {
epics = await service.listEpics(projectId);
} else {
epics = await service.listEpics();
}
return epics.map(withCompleteness);
},
);
app.get<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => {
return withCompleteness(await service.getEpic(request.params.id));
});
// ── Create / Update / Delete ──
app.post('/api/v1/epics', async (request, reply) => {
const body = request.body as Record<string, unknown>;
const resolved: Record<string, unknown> = { ...body };
// Accept `project` by name, same as skills/prompts do.
if (typeof body['project'] === 'string') {
const proj = await projectRepo.findByName(body['project']);
if (proj === null) {
throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 });
}
resolved['projectId'] = proj.id;
delete resolved['project'];
}
const ownerId = request.userId ?? 'system';
const epic = await service.createEpic(resolved, ownerId);
reply.code(201);
return withCompleteness(epic);
});
app.put<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => {
return withCompleteness(await service.updateEpic(request.params.id, request.body));
});
app.delete<{ Params: { id: string } }>('/api/v1/epics/:id', async (request, reply) => {
await service.deleteEpic(request.params.id);
reply.code(204);
});
// ── The honest-done gate ──
// Separate from PUT so the rule cannot be bypassed by a plain field write.
app.post<{ Params: { id: string } }>('/api/v1/epics/:id/status', async (request) => {
return withCompleteness(await service.setStatus(request.params.id, request.body));
});
app.post<{ Params: { id: string } }>('/api/v1/epics/:id/tradeoffs', async (request, reply) => {
const epic = await service.addTradeoff(request.params.id, request.body, request.userId);
reply.code(201);
return withCompleteness(epic);
});
app.post<{ Params: { id: string; criterionId: string }; Body: { met?: boolean } }>(
'/api/v1/epics/:id/criteria/:criterionId',
async (request) => {
const met = request.body?.met ?? true;
return withCompleteness(
await service.setCriterionMet(request.params.id, request.params.criterionId, met),
);
},
);
// ── Project-scoped view ──
app.get<{ Params: { name: string } }>('/api/v1/projects/:name/epics', async (request) => {
const proj = await projectRepo.findByName(request.params.name);
if (proj === null) {
throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 });
}
return (await service.listEpics(proj.id)).map(withCompleteness);
});
}

View File

@@ -0,0 +1,279 @@
import type { Epic, EpicStatus, Prisma } from '@prisma/client';
import type { IEpicRepository } from '../repositories/epic.repository.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
import {
CreateEpicSchema,
UpdateEpicSchema,
SetEpicStatusSchema,
AddTradeoffSchema,
type AcceptedTradeoff,
type DoneCriterion,
} from '../validation/epic.schema.js';
import { bumpSemver, type BumpKind } from '../utils/semver.js';
import { NotFoundError } from './mcp-server.service.js';
import type { ResourceRevisionService } from './resource-revision.service.js';
export interface EpicCompleteness {
total: number;
met: number;
/** 0-100, rounded. 100 with zero criteria means "nothing claimed yet". */
percent: number;
unmet: DoneCriterion[];
}
function badRequest(message: string): Error {
return Object.assign(new Error(message), { statusCode: 400 });
}
function readCriteria(epic: Epic): DoneCriterion[] {
return (epic.doneCriteria ?? []) as unknown as DoneCriterion[];
}
function readTradeoffs(epic: Epic): AcceptedTradeoff[] {
return (epic.acceptedTradeoffs ?? []) as unknown as AcceptedTradeoff[];
}
export class EpicService {
private revisionService: ResourceRevisionService | null = null;
constructor(
private readonly epicRepo: IEpicRepository,
private readonly projectRepo: IProjectRepository,
) {}
setRevisionService(service: ResourceRevisionService): void {
this.revisionService = service;
}
// ── Completeness ──
/**
* How much of the vision is actually delivered. Both `outcome` and `quality`
* criteria count — grading only the mechanical half is precisely how you end
* up with every task done and no car.
*/
static completeness(epic: Epic): EpicCompleteness {
const criteria = readCriteria(epic);
const met = criteria.filter((c) => c.met).length;
const total = criteria.length;
return {
total,
met,
percent: total === 0 ? 0 : Math.round((met / total) * 100),
unmet: criteria.filter((c) => !c.met),
};
}
// ── CRUD ──
async listEpics(projectId?: string): Promise<Epic[]> {
return this.epicRepo.findAll(projectId);
}
async getEpic(id: string): Promise<Epic> {
const epic = await this.epicRepo.findById(id);
if (epic === null) throw new NotFoundError(`Epic not found: ${id}`);
return epic;
}
async createEpic(input: unknown, ownerId: string): Promise<Epic> {
const data = CreateEpicSchema.parse(input);
const project = await this.projectRepo.findById(data.projectId);
if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);
const duplicate = await this.epicRepo.findByNameAndProject(data.name, data.projectId);
if (duplicate !== null) {
throw Object.assign(
new Error(`Epic '${data.name}' already exists in project '${project.name}'`),
{ statusCode: 409 },
);
}
this.assertCriteriaIdsUnique(data.doneCriteria);
const epic = await this.epicRepo.create({
name: data.name,
content: data.content,
projectId: data.projectId,
ownerId,
...(data.description !== undefined ? { description: data.description } : {}),
...(data.doneCriteria !== undefined
? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue }
: {}),
...(data.acceptedTradeoffs !== undefined
? { acceptedTradeoffs: data.acceptedTradeoffs as unknown as Prisma.InputJsonValue }
: {}),
...(data.semver !== undefined ? { semver: data.semver } : {}),
});
if (this.revisionService) {
this.recordRevision(epic, epic.semver, 'created').catch(() => {});
}
return epic;
}
async updateEpic(id: string, input: unknown): Promise<Epic> {
const data = UpdateEpicSchema.parse(input);
if (data.semver !== undefined && data.bump !== undefined) {
throw badRequest('Pass semver or bump, not both');
}
const existing = await this.getEpic(id);
this.assertCriteriaIdsUnique(data.doneCriteria);
const contentChanged =
data.content !== undefined || data.description !== undefined || data.doneCriteria !== undefined;
let newSemver = existing.semver;
if (data.semver !== undefined) {
newSemver = data.semver;
} else if (data.bump !== undefined) {
newSemver = bumpSemver(existing.semver, data.bump as BumpKind);
} else if (contentChanged) {
// Scope changes are versioned so creep is as visible as under-delivery.
newSemver = bumpSemver(existing.semver, 'patch');
}
const epic = await this.epicRepo.update(id, {
...(data.content !== undefined ? { content: data.content } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.doneCriteria !== undefined
? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue }
: {}),
...(newSemver !== existing.semver ? { semver: newSemver } : {}),
});
if (this.revisionService && (contentChanged || data.bump !== undefined || data.semver !== undefined)) {
this.recordRevision(epic, newSemver, data.note ?? null).catch(() => {});
}
return epic;
}
async deleteEpic(id: string): Promise<void> {
await this.getEpic(id); // 404 if missing
await this.epicRepo.delete(id);
}
// ── The honest-done rule ──
/**
* Move an Epic to a terminal status.
*
* An Epic is done ONLY when 100% of its criteria are met, OR the user
* explicitly accepted the gap. Anything else is a stick with wheels being
* called a car, so:
*
* - `delivered` requires every criterion met, and no recorded tradeoffs
* (if you accepted tradeoffs, the honest status names them).
* - `delivered_with_tradeoffs` requires at least one recorded tradeoff —
* otherwise it is just `delivered` wearing a disguise.
* - `cancelled` and `open` are always allowed; abandoning work needs no
* ceremony, and reopening is how a gap gets fixed.
*/
async setStatus(id: string, input: unknown): Promise<Epic> {
const { status, note } = SetEpicStatusSchema.parse(input);
const epic = await this.getEpic(id);
if (status === 'delivered') {
const { unmet, total, met } = EpicService.completeness(epic);
if (total === 0) {
throw badRequest(
'Cannot mark an Epic delivered with no done-criteria — there is nothing to have delivered',
);
}
if (unmet.length > 0) {
const names = unmet.slice(0, 5).map((c) => c.id).join(', ');
const more = unmet.length > 5 ? ` (+${String(unmet.length - 5)} more)` : '';
throw badRequest(
`Cannot mark delivered: ${String(met)}/${String(total)} criteria met. ` +
`Unmet: ${names}${more}. Meet them, or record the accepted tradeoffs and use delivered_with_tradeoffs.`,
);
}
if (readTradeoffs(epic).length > 0) {
throw badRequest(
'This Epic has accepted tradeoffs recorded — use delivered_with_tradeoffs so "done" stays honest',
);
}
}
if (status === 'delivered_with_tradeoffs' && readTradeoffs(epic).length === 0) {
throw badRequest(
'Cannot mark delivered_with_tradeoffs without recording at least one accepted tradeoff',
);
}
const updated = await this.epicRepo.update(id, { status: status as EpicStatus });
if (this.revisionService) {
this.recordRevision(updated, updated.semver, note ?? `status → ${status}`).catch(() => {});
}
return updated;
}
/** Append an accepted tradeoff. Append-only — the record is the audit trail. */
async addTradeoff(id: string, input: unknown, who?: string): Promise<Epic> {
const parsed = AddTradeoffSchema.parse(input);
const epic = await this.getEpic(id);
const entry: AcceptedTradeoff = {
...parsed,
...(parsed.who === undefined && who !== undefined ? { who } : {}),
at: parsed.at ?? new Date().toISOString(),
};
const next = [...readTradeoffs(epic), entry];
const updated = await this.epicRepo.update(id, {
acceptedTradeoffs: next as unknown as Prisma.InputJsonValue,
});
if (this.revisionService) {
this.recordRevision(updated, updated.semver, `accepted tradeoff: ${entry.what}`).catch(() => {});
}
return updated;
}
/** Flip one criterion's met flag without rewriting the whole array. */
async setCriterionMet(id: string, criterionId: string, met: boolean): Promise<Epic> {
const epic = await this.getEpic(id);
const criteria = readCriteria(epic);
const idx = criteria.findIndex((c) => c.id === criterionId);
if (idx === -1) throw new NotFoundError(`Done-criterion not found on epic: ${criterionId}`);
const next = criteria.map((c, i) => (i === idx ? { ...c, met } : c));
return this.epicRepo.update(id, { doneCriteria: next as unknown as Prisma.InputJsonValue });
}
// ── Internals ──
private assertCriteriaIdsUnique(criteria?: DoneCriterion[]): void {
if (criteria === undefined) return;
const seen = new Set<string>();
for (const c of criteria) {
if (seen.has(c.id)) {
throw badRequest(`Duplicate done-criterion id: ${c.id}`);
}
seen.add(c.id);
}
}
/** Best-effort revision write — same shape as PromptService / SkillService. */
private async recordRevision(epic: Epic, semver: string, note: string | null): Promise<void> {
if (this.revisionService === null) return;
const { revision } = await this.revisionService.record({
resourceType: 'epic',
resourceId: epic.id,
semver,
body: {
content: epic.content,
description: epic.description,
doneCriteria: epic.doneCriteria,
acceptedTradeoffs: epic.acceptedTradeoffs,
status: epic.status,
},
...(note !== null ? { note } : {}),
});
await this.epicRepo.update(epic.id, { currentRevisionId: revision.id });
}
}

View File

@@ -0,0 +1,67 @@
import { z } from 'zod';
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
const NAME_RE = /^[a-z0-9-]+$/;
/**
* A single checkable outcome on the Epic. `kind` separates the two halves of
* the two-tier Definition of Done:
*
* - `outcome` — mechanically checkable ("the CLI can create a task").
* - `quality` — the intent/feel attributes that decide whether the delivered
* whole is a car or a stick with wheels ("the roadmap reads at a glance").
*
* Both count toward completeness. Dropping `quality` from the count is exactly
* the failure the Epic exists to prevent.
*/
export const DoneCriterionSchema = z.object({
id: z.string().min(1).max(100),
text: z.string().min(1).max(2000),
kind: z.enum(['outcome', 'quality']).default('outcome'),
met: z.boolean().default(false),
});
/**
* A tradeoff the user explicitly accepted. Recorded rather than inferred so
* "done" stays honest and auditable: who de-scoped what, and why.
*/
export const AcceptedTradeoffSchema = z.object({
what: z.string().min(1).max(2000),
why: z.string().min(1).max(2000),
who: z.string().min(1).max(200).optional(),
deScopedBy: z.string().min(1).max(200).optional(),
at: z.string().datetime().optional(),
});
export const CreateEpicSchema = z.object({
name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'),
content: z.string().min(1).max(200_000),
description: z.string().max(500).optional(),
doneCriteria: z.array(DoneCriterionSchema).max(200).optional(),
acceptedTradeoffs: z.array(AcceptedTradeoffSchema).max(200).optional(),
projectId: z.string().min(1),
semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(),
});
export const UpdateEpicSchema = z.object({
content: z.string().min(1).max(200_000).optional(),
description: z.string().max(500).optional(),
doneCriteria: z.array(DoneCriterionSchema).max(200).optional(),
// Status is deliberately NOT settable here — reaching a terminal status goes
// through EpicService.setStatus, which enforces the honest-done rule.
semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(),
bump: z.enum(['major', 'minor', 'patch']).optional(),
note: z.string().max(500).optional(),
});
export const SetEpicStatusSchema = z.object({
status: z.enum(['open', 'delivered', 'delivered_with_tradeoffs', 'cancelled']),
note: z.string().max(500).optional(),
});
export const AddTradeoffSchema = AcceptedTradeoffSchema;
export type CreateEpicInput = z.infer<typeof CreateEpicSchema>;
export type UpdateEpicInput = z.infer<typeof UpdateEpicSchema>;
export type DoneCriterion = z.infer<typeof DoneCriterionSchema>;
export type AcceptedTradeoff = z.infer<typeof AcceptedTradeoffSchema>;

View File

@@ -0,0 +1,222 @@
import type { Epic, Project } from '@prisma/client';
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { IEpicRepository } from '../src/repositories/epic.repository.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import { registerEpicRoutes } from '../src/routes/epics.js';
import { EpicService } from '../src/services/epic.service.js';
let app: FastifyInstance;
function makeEpic(overrides: Partial<Epic> = {}): Epic {
return {
id: 'epic-1',
name: 'the-car',
description: '',
content: 'build a car',
doneCriteria: [],
acceptedTradeoffs: [],
projectId: 'proj-1',
status: 'open',
semver: '0.1.0',
currentRevisionId: null,
ownerId: 'user-1',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as Epic;
}
const makeProject = (o: Partial<Project> = {}): Project =>
({ id: 'proj-1', name: 'mcpctl', ...o } as Project);
const criterion = (id: string, met: boolean) =>
({ id, text: `criterion ${id}`, kind: 'outcome' as const, met });
async function buildApp(epic: Epic | null = makeEpic(), userId?: string): Promise<{
app: FastifyInstance;
epicRepo: IEpicRepository & { update: ReturnType<typeof vi.fn> };
}> {
let current = epic;
const update = vi.fn(async (_id: string, data: Record<string, unknown>) => {
current = { ...(current as Epic), ...data } as Epic;
return current;
});
const epicRepo = {
findAll: vi.fn(async () => (current ? [current] : [])),
findById: vi.fn(async () => current),
findByName: vi.fn(async () => current),
findByNameAndProject: vi.fn(async () => null),
create: vi.fn(async (data: Record<string, unknown>) => makeEpic(data as Partial<Epic>)),
update,
delete: vi.fn(async () => {}),
} as unknown as IEpicRepository & { update: ReturnType<typeof vi.fn> };
const projectRepo = {
findById: vi.fn(async () => makeProject()),
findByName: vi.fn(async (n: string) => (n === 'mcpctl' ? makeProject() : null)),
} as unknown as IProjectRepository;
const instance = Fastify();
instance.setErrorHandler(errorHandler);
// Stand in for the auth middleware, which normally sets request.userId.
if (userId !== undefined) {
instance.addHook('onRequest', async (req) => { req.userId = userId; });
}
registerEpicRoutes(instance, new EpicService(epicRepo, projectRepo), projectRepo);
await instance.ready();
return { app: instance, epicRepo };
}
afterEach(async () => {
if (app) await app.close();
});
describe('epic routes', () => {
it('attaches computed completeness to list and get', async () => {
const built = await buildApp(
makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }),
);
app = built.app;
const list = await app.inject({ method: 'GET', url: '/api/v1/epics' });
expect(list.statusCode).toBe(200);
expect(list.json()[0].completeness).toMatchObject({ total: 2, met: 1, percent: 50 });
const one = await app.inject({ method: 'GET', url: '/api/v1/epics/epic-1' });
expect(one.json().completeness.unmet).toHaveLength(1);
});
it('creates from a project name and stamps the caller as owner', async () => {
const built = await buildApp(null, 'user-42');
app = built.app;
const res = await app.inject({
method: 'POST',
url: '/api/v1/epics',
payload: { name: 'the-car', content: 'build a car', project: 'mcpctl' },
});
expect(res.statusCode).toBe(201);
expect(built.epicRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'proj-1', ownerId: 'user-42' }),
);
});
it('404s on an unknown project name', async () => {
const built = await buildApp(null);
app = built.app;
const res = await app.inject({
method: 'POST',
url: '/api/v1/epics',
payload: { name: 'x', content: 'c', project: 'ghost' },
});
expect(res.statusCode).toBe(404);
});
it('400s a bad payload rather than 500ing', async () => {
const built = await buildApp(null);
app = built.app;
const res = await app.inject({
method: 'POST',
url: '/api/v1/epics',
payload: { name: 'Not Kebab', content: 'c', project: 'mcpctl' },
});
expect(res.statusCode).toBe(400);
});
it('400s an unhonest delivered, and explains what is missing', async () => {
const built = await buildApp(
makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }),
);
app = built.app;
const res = await app.inject({
method: 'POST',
url: '/api/v1/epics/epic-1/status',
payload: { status: 'delivered' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error ?? res.json().message).toMatch(/1\/2 criteria met/);
});
it('accepts delivered once everything is met', async () => {
const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', true)] as never }));
app = built.app;
const res = await app.inject({
method: 'POST',
url: '/api/v1/epics/epic-1/status',
payload: { status: 'delivered' },
});
expect(res.statusCode).toBe(200);
expect(res.json().status).toBe('delivered');
expect(res.json().completeness.percent).toBe(100);
});
it('records a tradeoff and returns 201', async () => {
const built = await buildApp(makeEpic(), 'user-42');
app = built.app;
const res = await app.inject({
method: 'POST',
url: '/api/v1/epics/epic-1/tradeoffs',
payload: { what: 'no dark mode', why: 'out of budget' },
});
expect(res.statusCode).toBe(201);
const written = built.epicRepo.update.mock.calls[0]?.[1] as {
acceptedTradeoffs: Array<{ what: string; who?: string }>;
};
expect(written.acceptedTradeoffs[0]).toMatchObject({ what: 'no dark mode', who: 'user-42' });
});
it('flips a single criterion, defaulting met to true', async () => {
const built = await buildApp(
makeEpic({ doneCriteria: [criterion('a', false), criterion('b', false)] as never }),
);
app = built.app;
const res = await app.inject({
method: 'POST',
url: '/api/v1/epics/epic-1/criteria/b',
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.json().completeness).toMatchObject({ met: 1, total: 2 });
});
it('404s an unknown criterion', async () => {
const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
app = built.app;
const res = await app.inject({
method: 'POST',
url: '/api/v1/epics/epic-1/criteria/ghost',
payload: { met: true },
});
expect(res.statusCode).toBe(404);
});
it('deletes with 204', async () => {
const built = await buildApp();
app = built.app;
const res = await app.inject({ method: 'DELETE', url: '/api/v1/epics/epic-1' });
expect(res.statusCode).toBe(204);
});
it('serves the project-scoped view', async () => {
const built = await buildApp();
app = built.app;
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/mcpctl/epics' });
expect(res.statusCode).toBe(200);
expect(res.json()).toHaveLength(1);
const missing = await app.inject({ method: 'GET', url: '/api/v1/projects/ghost/epics' });
expect(missing.statusCode).toBe(404);
});
});

View File

@@ -0,0 +1,283 @@
import type { Epic, Project } from '@prisma/client';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { IEpicRepository } from '../../src/repositories/epic.repository.js';
import type { IProjectRepository } from '../../src/repositories/project.repository.js';
import { EpicService } from '../../src/services/epic.service.js';
function makeEpic(overrides: Partial<Epic> = {}): Epic {
return {
id: 'epic-1',
name: 'the-car',
description: '',
content: 'build a car',
doneCriteria: [],
acceptedTradeoffs: [],
projectId: 'proj-1',
status: 'open',
semver: '0.1.0',
currentRevisionId: null,
ownerId: 'user-1',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as Epic;
}
const criterion = (id: string, met: boolean, kind: 'outcome' | 'quality' = 'outcome') =>
({ id, text: `criterion ${id}`, kind, met });
function mockRepos(epic: Epic | null = makeEpic()) {
let current = epic;
const update = vi.fn(async (_id: string, data: Record<string, unknown>) => {
current = { ...(current as Epic), ...data } as Epic;
return current;
});
const epicRepo = {
findAll: vi.fn(async () => (current ? [current] : [])),
findById: vi.fn(async () => current),
findByName: vi.fn(async () => current),
findByNameAndProject: vi.fn(async () => null),
create: vi.fn(async (data: Record<string, unknown>) => makeEpic(data as Partial<Epic>)),
update,
delete: vi.fn(async () => {}),
} as unknown as IEpicRepository & { update: typeof update };
const projectRepo = {
findById: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)),
findByName: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)),
} as unknown as IProjectRepository;
return { epicRepo, projectRepo, service: new EpicService(epicRepo, projectRepo) };
}
describe('EpicService.completeness', () => {
it('is 0% when nothing is claimed yet', () => {
expect(EpicService.completeness(makeEpic()).percent).toBe(0);
});
it('counts quality criteria, not just mechanical outcomes', () => {
// The whole point of the Epic: grading only the checkable half is how you
// end up with every task done and no car.
const epic = makeEpic({
doneCriteria: [criterion('a', true), criterion('feels-finished', false, 'quality')] as never,
});
const c = EpicService.completeness(epic);
expect(c.total).toBe(2);
expect(c.met).toBe(1);
expect(c.percent).toBe(50);
expect(c.unmet.map((u) => u.id)).toEqual(['feels-finished']);
});
it('rounds to whole percent', () => {
const epic = makeEpic({
doneCriteria: [criterion('a', true), criterion('b', false), criterion('c', false)] as never,
});
expect(EpicService.completeness(epic).percent).toBe(33);
});
});
describe('EpicService.setStatus — the honest-done rule', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('refuses delivered while criteria are unmet, and names them', async () => {
const { service } = mockRepos(
makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }),
);
await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(
/1\/2 criteria met.*Unmet: b/s,
);
});
it('truncates a long unmet list rather than dumping all of it', async () => {
const many = Array.from({ length: 9 }, (_, i) => criterion(`c${String(i)}`, false));
const { service } = mockRepos(makeEpic({ doneCriteria: many as never }));
await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(/\+4 more/);
});
it('refuses delivered when no criteria exist at all', async () => {
const { service } = mockRepos(makeEpic({ doneCriteria: [] as never }));
await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(
/nothing to have delivered/,
);
});
it('allows delivered when every criterion is met', async () => {
const { service, epicRepo } = mockRepos(
makeEpic({ doneCriteria: [criterion('a', true), criterion('b', true)] as never }),
);
const result = await service.setStatus('epic-1', { status: 'delivered' });
expect(result.status).toBe('delivered');
expect(epicRepo.update).toHaveBeenCalledWith('epic-1', { status: 'delivered' });
});
it('refuses plain delivered when tradeoffs were accepted — the status must name them', async () => {
const { service } = mockRepos(
makeEpic({
doneCriteria: [criterion('a', true)] as never,
acceptedTradeoffs: [{ what: 'no dark mode', why: 'budget' }] as never,
}),
);
await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(
/use delivered_with_tradeoffs/,
);
});
it('refuses delivered_with_tradeoffs when none were recorded', async () => {
const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
await expect(
service.setStatus('epic-1', { status: 'delivered_with_tradeoffs' }),
).rejects.toThrow(/without recording at least one accepted tradeoff/);
});
it('allows delivered_with_tradeoffs even with unmet criteria, once the gap is recorded', async () => {
const { service } = mockRepos(
makeEpic({
doneCriteria: [criterion('a', true), criterion('b', false)] as never,
acceptedTradeoffs: [{ what: 'b', why: 'deferred to next quarter' }] as never,
}),
);
const result = await service.setStatus('epic-1', { status: 'delivered_with_tradeoffs' });
expect(result.status).toBe('delivered_with_tradeoffs');
});
it('always allows cancelled and reopening', async () => {
const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
await expect(service.setStatus('epic-1', { status: 'cancelled' })).resolves.toBeDefined();
await expect(service.setStatus('epic-1', { status: 'open' })).resolves.toBeDefined();
});
it('rejects an unknown status', async () => {
const { service } = mockRepos();
await expect(service.setStatus('epic-1', { status: 'shipped' })).rejects.toThrow();
});
});
describe('EpicService.addTradeoff', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('appends rather than replacing — the record is the audit trail', async () => {
const { service, epicRepo } = mockRepos(
makeEpic({ acceptedTradeoffs: [{ what: 'first', why: 'x', at: '2026-01-01T00:00:00.000Z' }] as never }),
);
await service.addTradeoff('epic-1', { what: 'second', why: 'y' });
const written = epicRepo.update.mock.calls[0]?.[1] as { acceptedTradeoffs: Array<{ what: string }> };
expect(written.acceptedTradeoffs.map((t) => t.what)).toEqual(['first', 'second']);
});
it('stamps who from the caller and when from the clock', async () => {
const { service, epicRepo } = mockRepos();
await service.addTradeoff('epic-1', { what: 'no dark mode', why: 'budget' }, 'user-42');
const written = epicRepo.update.mock.calls[0]?.[1] as {
acceptedTradeoffs: Array<{ who?: string; at?: string }>;
};
expect(written.acceptedTradeoffs[0]?.who).toBe('user-42');
expect(written.acceptedTradeoffs[0]?.at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('does not overwrite an explicitly supplied who', async () => {
const { service, epicRepo } = mockRepos();
await service.addTradeoff('epic-1', { what: 'x', why: 'y', who: 'the-cto' }, 'user-42');
const written = epicRepo.update.mock.calls[0]?.[1] as { acceptedTradeoffs: Array<{ who?: string }> };
expect(written.acceptedTradeoffs[0]?.who).toBe('the-cto');
});
it('requires both what and why', async () => {
const { service } = mockRepos();
await expect(service.addTradeoff('epic-1', { what: 'x' })).rejects.toThrow();
await expect(service.addTradeoff('epic-1', { why: 'y' })).rejects.toThrow();
});
});
describe('EpicService.setCriterionMet', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('flips one flag and leaves the rest alone', async () => {
const { service, epicRepo } = mockRepos(
makeEpic({ doneCriteria: [criterion('a', false), criterion('b', false)] as never }),
);
await service.setCriterionMet('epic-1', 'b', true);
const written = epicRepo.update.mock.calls[0]?.[1] as {
doneCriteria: Array<{ id: string; met: boolean }>;
};
expect(written.doneCriteria).toEqual([
expect.objectContaining({ id: 'a', met: false }),
expect.objectContaining({ id: 'b', met: true }),
]);
});
it('404s on an unknown criterion', async () => {
const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
await expect(service.setCriterionMet('epic-1', 'nope', true)).rejects.toThrow(/not found/i);
});
});
describe('EpicService create/update', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('rejects duplicate criterion ids', async () => {
const { service } = mockRepos(null);
await expect(
service.createEpic(
{
name: 'x', content: 'c', projectId: 'proj-1',
doneCriteria: [criterion('dup', false), criterion('dup', true)],
},
'user-1',
),
).rejects.toThrow(/Duplicate done-criterion id: dup/);
});
it('409s on a duplicate epic name within the project', async () => {
const { service, epicRepo } = mockRepos(null);
(epicRepo.findByNameAndProject as ReturnType<typeof vi.fn>).mockResolvedValueOnce(makeEpic());
await expect(
service.createEpic({ name: 'the-car', content: 'c', projectId: 'proj-1' }, 'user-1'),
).rejects.toThrow(/already exists in project 'mcpctl'/);
});
it('404s when the project does not exist', async () => {
const { service, projectRepo } = mockRepos(null);
(projectRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
await expect(
service.createEpic({ name: 'x', content: 'c', projectId: 'ghost' }, 'user-1'),
).rejects.toThrow(/Project not found/);
});
it('rejects a name that is not lowercase-kebab', async () => {
const { service } = mockRepos(null);
await expect(
service.createEpic({ name: 'The Car', content: 'c', projectId: 'proj-1' }, 'user-1'),
).rejects.toThrow();
});
it('patch-bumps semver when scope changes, so creep is versioned', async () => {
const { service, epicRepo } = mockRepos(makeEpic({ semver: '1.2.3' }));
await service.updateEpic('epic-1', { doneCriteria: [criterion('new', false)] });
expect(epicRepo.update.mock.calls[0]?.[1]).toMatchObject({ semver: '1.2.4' });
});
it('honours an explicit bump', async () => {
const { service, epicRepo } = mockRepos(makeEpic({ semver: '1.2.3' }));
await service.updateEpic('epic-1', { content: 'rewritten', bump: 'minor' });
expect(epicRepo.update.mock.calls[0]?.[1]).toMatchObject({ semver: '1.3.0' });
});
it('refuses semver and bump together', async () => {
const { service } = mockRepos();
await expect(
service.updateEpic('epic-1', { content: 'x', semver: '2.0.0', bump: 'major' }),
).rejects.toThrow(/not both/);
});
it('does not let a plain update set status — that gate is setStatus only', async () => {
const { service, epicRepo } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
await service.updateEpic('epic-1', { content: 'x', status: 'delivered' } as never);
const written = epicRepo.update.mock.calls[0]?.[1] as Record<string, unknown>;
expect(written['status']).toBeUndefined();
});
});