feat(mcpd): Epic resource — the vision layer, with an honest-done gate
Full CRUD for Epic: repository, zod validation, service, routes, main.ts wiring, and revision tracking (ResourceType gains 'epic', which is a plain string union by design so this needed no migration). The substance is the done rule, not the CRUD. An Epic exists to stop "every task completed, still no car", so status is deliberately NOT settable through PUT — it goes through POST /epics/:id/status, which enforces: - `delivered` requires every done-criterion met. The 400 names the unmet ids (truncated past 5) so the caller knows what is actually missing. - `delivered` with recorded tradeoffs is refused — if you accepted a gap, the status has to say so. - `delivered_with_tradeoffs` requires at least one recorded tradeoff, otherwise it is `delivered` wearing a disguise. - `delivered` with zero criteria is refused: nothing was claimed, so nothing can have been delivered. - `cancelled` and `open` are always allowed — abandoning needs no ceremony, and reopening is how a gap gets fixed. Completeness counts `quality` criteria alongside `outcome` ones. Grading only the mechanically checkable half is exactly the failure the Epic exists to prevent, so both feed the percentage, and it is computed server-side and attached to every response rather than left for clients to re-derive. Tradeoffs are append-only and stamped with who and when — the record is the audit trail for why "done" was honest. Scope changes patch-bump semver, so creep is versioned as visibly as under-delivery. Tests: 26 service tests (the full done-rule matrix, tradeoff append/stamping, criterion flipping, semver bumping, and that a plain update cannot smuggle a status through) and 11 route tests (completeness attachment, owner stamping, 400 vs 404 vs 500 behaviour, the status gate over HTTP). Local: build clean, workspace 2347 passed (+37), 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:
@@ -2669,9 +2669,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"117"
|
"117"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T10:05:28.155Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "124",
|
"id": "124",
|
||||||
@@ -2683,9 +2683,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"123"
|
"123"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T10:05:28.165Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "125",
|
"id": "125",
|
||||||
@@ -2697,9 +2697,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"117"
|
"117"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T10:05:28.172Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "126",
|
"id": "126",
|
||||||
@@ -2712,9 +2712,9 @@
|
|||||||
"124",
|
"124",
|
||||||
"125"
|
"125"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T10:05:28.178Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "127",
|
"id": "127",
|
||||||
@@ -3251,9 +3251,9 @@
|
|||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-08-08T09:36:21.139Z",
|
"lastModified": "2026-08-08T10:05:28.178Z",
|
||||||
"taskCount": 162,
|
"taskCount": 162,
|
||||||
"completedCount": 107,
|
"completedCount": 111,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ import {
|
|||||||
import { registerPromptRoutes } from './routes/prompts.js';
|
import { registerPromptRoutes } from './routes/prompts.js';
|
||||||
import { registerRevisionRoutes } from './routes/revisions.js';
|
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 { 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';
|
||||||
@@ -113,7 +114,9 @@ import { ResourceRevisionRepository } from './repositories/resource-revision.rep
|
|||||||
import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js';
|
import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js';
|
||||||
import { ResourceRevisionService } from './services/resource-revision.service.js';
|
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 { SkillRepository } from './repositories/skill.repository.js';
|
import { SkillRepository } from './repositories/skill.repository.js';
|
||||||
|
import { EpicService } from './services/epic.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';
|
||||||
@@ -221,6 +224,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
// Personalities inherit the agent's RBAC: managing a personality
|
// Personalities inherit the agent's RBAC: managing a personality
|
||||||
// requires view/edit/create/delete on the `agents` resource.
|
// requires view/edit/create/delete on the `agents` resource.
|
||||||
'personalities': 'agents',
|
'personalities': 'agents',
|
||||||
|
// ── Agentic teams ──
|
||||||
|
'epics': 'epics',
|
||||||
};
|
};
|
||||||
|
|
||||||
const resource = resourceMap[segment];
|
const resource = resourceMap[segment];
|
||||||
@@ -427,6 +432,9 @@ async function main(): Promise<void> {
|
|||||||
const userRepo = new UserRepository(prisma);
|
const userRepo = new UserRepository(prisma);
|
||||||
const groupRepo = new GroupRepository(prisma);
|
const groupRepo = new GroupRepository(prisma);
|
||||||
const mcpTokenRepo = new McpTokenRepository(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
|
// 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.
|
||||||
@@ -443,6 +451,7 @@ async function main(): Promise<void> {
|
|||||||
mcptokens: mcpTokenRepo,
|
mcptokens: mcpTokenRepo,
|
||||||
llms: llmRepo,
|
llms: llmRepo,
|
||||||
agents: agentRepo,
|
agents: agentRepo,
|
||||||
|
epics: epicRepo,
|
||||||
// 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) => {
|
||||||
@@ -545,6 +554,11 @@ async function main(): Promise<void> {
|
|||||||
const skillService = new SkillService(skillRepo, projectRepo, agentRepo);
|
const skillService = new SkillService(skillRepo, projectRepo, agentRepo);
|
||||||
skillService.setRevisionService(resourceRevisionService);
|
skillService.setRevisionService(resourceRevisionService);
|
||||||
skillService.setProposalService(resourceProposalService);
|
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 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);
|
||||||
@@ -753,6 +767,7 @@ async function main(): Promise<void> {
|
|||||||
registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService });
|
registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService });
|
||||||
registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo });
|
registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo });
|
||||||
registerSkillRoutes(app, skillService, projectRepo, agentRepo);
|
registerSkillRoutes(app, skillService, projectRepo, agentRepo);
|
||||||
|
registerEpicRoutes(app, epicService, projectRepo);
|
||||||
|
|
||||||
// ── Git-based backup ──
|
// ── Git-based backup ──
|
||||||
const gitBackup = new GitBackupService(prisma);
|
const gitBackup = new GitBackupService(prisma);
|
||||||
|
|||||||
91
src/mcpd/src/repositories/epic.repository.ts
Normal file
91
src/mcpd/src/repositories/epic.repository.ts
Normal 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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import type { PrismaClient, Prisma, ResourceRevision } from '@prisma/client';
|
|||||||
* service layer to keep them in sync with real IDs.
|
* service layer to keep them in sync with real IDs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ResourceType = 'prompt' | 'skill';
|
export type ResourceType = 'prompt' | 'skill' | 'epic';
|
||||||
|
|
||||||
export interface CreateRevisionInput {
|
export interface CreateRevisionInput {
|
||||||
resourceType: ResourceType;
|
resourceType: ResourceType;
|
||||||
|
|||||||
107
src/mcpd/src/routes/epics.ts
Normal file
107
src/mcpd/src/routes/epics.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
279
src/mcpd/src/services/epic.service.ts
Normal file
279
src/mcpd/src/services/epic.service.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/mcpd/src/validation/epic.schema.ts
Normal file
67
src/mcpd/src/validation/epic.schema.ts
Normal 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>;
|
||||||
222
src/mcpd/tests/epic-routes.test.ts
Normal file
222
src/mcpd/tests/epic-routes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
283
src/mcpd/tests/services/epic-service.test.ts
Normal file
283
src/mcpd/tests/services/epic-service.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user