feat(mcpd): EpicTask resource — roadmap nodes, edges, and the roadmap view
Roadmap nodes plus the edges that chain them into the dependency graph the design doc calls the spider web. Edge direction is "from depends on to". The database cannot enforce graph integrity here — production provisions with `prisma db push`, so a trigger or CHECK would never exist there, and the schema test added earlier documents that boundary explicitly. So the service rejects the three things that would otherwise corrupt the roadmap: - self-edges, - edges crossing Epics, since a roadmap is one Epic's graph, - cycles, which make the graph unschedulable and hang any topological walk. Cycle detection is a BFS over the prerequisite direction: adding from→to closes a loop exactly when `to` already depends transitively on `from`. The error names the offending path, not just "cycle", so a failed apply says which nodes to look at. The traversal carries a seen-set that also guarantees termination if the stored graph ALREADY contains a cycle — from a hand-edited database or an earlier bug — rather than spinning forever. There is a test for that case. Adding an existing edge is a no-op rather than an error, so applying a manifest twice is safe. GET /api/v1/epics/:id/roadmap returns nodes with per-node completeness, both edge directions, and `ready`: the nodes whose prerequisites are all satisfied, which is the question anyone actually asks of a roadmap. A cancelled prerequisite counts as satisfied — it is not coming. Completeness reuses EpicService.completeness verbatim so a node and its Epic can never disagree about what "met" means. Tests: 19 service tests — the full cycle matrix (self, 2-cycle, long transitive, diamond-is-fine, pre-existing-cycle termination), roadmap edge directions, readiness including the cancelled-prerequisite case, and CRUD guards. Local: build clean, workspace 2366 passed (+19), db 128 passed (same 4 pre-existing Secret failures), lint unchanged at 869. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -2727,9 +2727,9 @@
|
||||
"117",
|
||||
"123"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
||||
"updatedAt": "2026-08-08T10:10:39.751Z"
|
||||
},
|
||||
{
|
||||
"id": "128",
|
||||
@@ -2741,9 +2741,9 @@
|
||||
"dependencies": [
|
||||
"127"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
||||
"updatedAt": "2026-08-08T10:10:39.761Z"
|
||||
},
|
||||
{
|
||||
"id": "129",
|
||||
@@ -3251,9 +3251,9 @@
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-08-08T10:05:28.178Z",
|
||||
"lastModified": "2026-08-08T10:10:39.761Z",
|
||||
"taskCount": 162,
|
||||
"completedCount": 111,
|
||||
"completedCount": 113,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
@@ -107,6 +107,7 @@ 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 { registerEpicTaskRoutes } from './routes/epictasks.js';
|
||||
import { registerSkillRoutes } from './routes/skills.js';
|
||||
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
||||
import { PromptService } from './services/prompt.service.js';
|
||||
@@ -115,8 +116,10 @@ import { ResourceProposalRepository } from './repositories/resource-proposal.rep
|
||||
import { ResourceRevisionService } from './services/resource-revision.service.js';
|
||||
import { ResourceProposalService } from './services/resource-proposal.service.js';
|
||||
import { EpicRepository } from './repositories/epic.repository.js';
|
||||
import { EpicTaskRepository } from './repositories/epic-task.repository.js';
|
||||
import { SkillRepository } from './repositories/skill.repository.js';
|
||||
import { EpicService } from './services/epic.service.js';
|
||||
import { EpicTaskService } from './services/epic-task.service.js';
|
||||
import { SkillService } from './services/skill.service.js';
|
||||
import { GitBackupService } from './services/backup/git-backup.service.js';
|
||||
import type { BackupKind } from './services/backup/yaml-serializer.js';
|
||||
@@ -226,6 +229,7 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
||||
'personalities': 'agents',
|
||||
// ── Agentic teams ──
|
||||
'epics': 'epics',
|
||||
'epictasks': 'epictasks',
|
||||
};
|
||||
|
||||
const resource = resourceMap[segment];
|
||||
@@ -435,6 +439,7 @@ async function main(): Promise<void> {
|
||||
// Declared here (not with the other agentic-teams wiring below) because
|
||||
// nameResolvers needs it for CUID→name RBAC resolution.
|
||||
const epicRepo = new EpicRepository(prisma);
|
||||
const epicTaskRepo = new EpicTaskRepository(prisma);
|
||||
|
||||
// SecretBackend bootstrap: ensure a `plaintext` default row exists and any
|
||||
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
||||
@@ -452,6 +457,7 @@ async function main(): Promise<void> {
|
||||
llms: llmRepo,
|
||||
agents: agentRepo,
|
||||
epics: epicRepo,
|
||||
epictasks: epicTaskRepo,
|
||||
// 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) => {
|
||||
@@ -559,6 +565,7 @@ async function main(): Promise<void> {
|
||||
// Revision-tracked so scope creep is as visible as under-delivery.
|
||||
const epicService = new EpicService(epicRepo, projectRepo);
|
||||
epicService.setRevisionService(resourceRevisionService);
|
||||
const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo);
|
||||
const personalityRepo = new PersonalityRepository(prisma);
|
||||
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
|
||||
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
||||
@@ -768,6 +775,7 @@ async function main(): Promise<void> {
|
||||
registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo });
|
||||
registerSkillRoutes(app, skillService, projectRepo, agentRepo);
|
||||
registerEpicRoutes(app, epicService, projectRepo);
|
||||
registerEpicTaskRoutes(app, epicTaskService, epicRepo, epicTaskRepo, projectRepo);
|
||||
|
||||
// ── Git-based backup ──
|
||||
const gitBackup = new GitBackupService(prisma);
|
||||
|
||||
107
src/mcpd/src/repositories/epic-task.repository.ts
Normal file
107
src/mcpd/src/repositories/epic-task.repository.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { PrismaClient, Prisma, EpicTask, EpicTaskEdge, EpicTaskStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* EpicTask repository — roadmap nodes plus the edges that chain them.
|
||||
*
|
||||
* Edge direction is `from depends on to`: `fromId` is the dependent node,
|
||||
* `toId` the prerequisite. Both directions are indexed so the graph walks
|
||||
* either way (`dependsOn` / `blocks`).
|
||||
*/
|
||||
|
||||
export interface EpicTaskCreateInput {
|
||||
name: string;
|
||||
epicId: string;
|
||||
ownerId: string;
|
||||
description?: string;
|
||||
doneCriteria?: Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export interface EpicTaskUpdateInput {
|
||||
description?: string;
|
||||
doneCriteria?: Prisma.InputJsonValue;
|
||||
status?: EpicTaskStatus;
|
||||
}
|
||||
|
||||
export interface IEpicTaskRepository {
|
||||
findAll(epicId?: string): Promise<EpicTask[]>;
|
||||
findById(id: string): Promise<EpicTask | null>;
|
||||
findByName(name: string): Promise<EpicTask | null>;
|
||||
findByNameAndEpic(name: string, epicId: string): Promise<EpicTask | null>;
|
||||
create(data: EpicTaskCreateInput): Promise<EpicTask>;
|
||||
update(id: string, data: EpicTaskUpdateInput): Promise<EpicTask>;
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
// ── Edges ──
|
||||
findEdges(epicId: string): Promise<EpicTaskEdge[]>;
|
||||
findDependencies(fromId: string): Promise<EpicTaskEdge[]>;
|
||||
createEdge(fromId: string, toId: string): Promise<EpicTaskEdge>;
|
||||
deleteEdge(fromId: string, toId: string): Promise<void>;
|
||||
}
|
||||
|
||||
const NODE_INCLUDE = { epic: { select: { name: true, projectId: true } } } as const;
|
||||
|
||||
export class EpicTaskRepository implements IEpicTaskRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(epicId?: string): Promise<EpicTask[]> {
|
||||
return this.prisma.epicTask.findMany({
|
||||
...(epicId !== undefined ? { where: { epicId } } : {}),
|
||||
include: NODE_INCLUDE,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<EpicTask | null> {
|
||||
return this.prisma.epicTask.findUnique({ where: { id }, include: NODE_INCLUDE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve by bare name. Names are unique per Epic, not globally — this is for
|
||||
* the RBAC CUID→name resolver, which only needs *a* name to compare bindings.
|
||||
*/
|
||||
async findByName(name: string): Promise<EpicTask | null> {
|
||||
return this.prisma.epicTask.findFirst({ where: { name }, include: NODE_INCLUDE });
|
||||
}
|
||||
|
||||
async findByNameAndEpic(name: string, epicId: string): Promise<EpicTask | null> {
|
||||
return this.prisma.epicTask.findUnique({
|
||||
where: { name_epicId: { name, epicId } },
|
||||
include: NODE_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: EpicTaskCreateInput): Promise<EpicTask> {
|
||||
return this.prisma.epicTask.create({ data, include: NODE_INCLUDE });
|
||||
}
|
||||
|
||||
async update(id: string, data: EpicTaskUpdateInput): Promise<EpicTask> {
|
||||
return this.prisma.epicTask.update({
|
||||
where: { id },
|
||||
data: { ...data, version: { increment: 1 } },
|
||||
include: NODE_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.epicTask.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ── Edges ──
|
||||
|
||||
/** Every edge whose dependent node belongs to this Epic. */
|
||||
async findEdges(epicId: string): Promise<EpicTaskEdge[]> {
|
||||
return this.prisma.epicTaskEdge.findMany({ where: { from: { epicId } } });
|
||||
}
|
||||
|
||||
async findDependencies(fromId: string): Promise<EpicTaskEdge[]> {
|
||||
return this.prisma.epicTaskEdge.findMany({ where: { fromId } });
|
||||
}
|
||||
|
||||
async createEdge(fromId: string, toId: string): Promise<EpicTaskEdge> {
|
||||
return this.prisma.epicTaskEdge.create({ data: { fromId, toId } });
|
||||
}
|
||||
|
||||
async deleteEdge(fromId: string, toId: string): Promise<void> {
|
||||
await this.prisma.epicTaskEdge.deleteMany({ where: { fromId, toId } });
|
||||
}
|
||||
}
|
||||
127
src/mcpd/src/routes/epictasks.ts
Normal file
127
src/mcpd/src/routes/epictasks.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
import type { IEpicRepository } from '../repositories/epic.repository.js';
|
||||
import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js';
|
||||
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||
import type { EpicTaskService } from '../services/epic-task.service.js';
|
||||
import { CreateEdgeSchema } from '../validation/epic-task.schema.js';
|
||||
|
||||
export function registerEpicTaskRoutes(
|
||||
app: FastifyInstance,
|
||||
service: EpicTaskService,
|
||||
epicRepo: IEpicRepository,
|
||||
epicTaskRepo: IEpicTaskRepository,
|
||||
projectRepo: IProjectRepository,
|
||||
): void {
|
||||
/** Resolve `?epic=<name>` (+ optional `?project=`) to an epic id. */
|
||||
async function resolveEpicId(epicName: string, projectName?: string): Promise<string> {
|
||||
if (projectName !== undefined) {
|
||||
const proj = await projectRepo.findByName(projectName);
|
||||
if (proj === null) {
|
||||
throw Object.assign(new Error(`Project not found: ${projectName}`), { statusCode: 404 });
|
||||
}
|
||||
const epic = await epicRepo.findByNameAndProject(epicName, proj.id);
|
||||
if (epic === null) {
|
||||
throw Object.assign(
|
||||
new Error(`Epic not found: ${epicName} in project ${projectName}`),
|
||||
{ statusCode: 404 },
|
||||
);
|
||||
}
|
||||
return epic.id;
|
||||
}
|
||||
const epic = await epicRepo.findByName(epicName);
|
||||
if (epic === null) {
|
||||
throw Object.assign(new Error(`Epic not found: ${epicName}`), { statusCode: 404 });
|
||||
}
|
||||
return epic.id;
|
||||
}
|
||||
|
||||
// ── List / get ──
|
||||
|
||||
app.get<{ Querystring: { epic?: string; epicId?: string; project?: string } }>(
|
||||
'/api/v1/epictasks',
|
||||
async (request) => {
|
||||
const { epic, epicId, project } = request.query;
|
||||
if (epicId !== undefined) return service.list(epicId);
|
||||
if (epic !== undefined) return service.list(await resolveEpicId(epic, project));
|
||||
return service.list();
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/epictasks/:id', async (request) => {
|
||||
return service.get(request.params.id);
|
||||
});
|
||||
|
||||
// ── Create / update / delete ──
|
||||
|
||||
app.post('/api/v1/epictasks', async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const resolved: Record<string, unknown> = { ...body };
|
||||
|
||||
if (typeof body['epic'] === 'string') {
|
||||
resolved['epicId'] = await resolveEpicId(
|
||||
body['epic'],
|
||||
typeof body['project'] === 'string' ? body['project'] : undefined,
|
||||
);
|
||||
delete resolved['epic'];
|
||||
delete resolved['project'];
|
||||
}
|
||||
|
||||
const node = await service.create(resolved, request.userId ?? 'system');
|
||||
reply.code(201);
|
||||
return node;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/epictasks/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/epictasks/:id', async (request, reply) => {
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
// ── Edges ──
|
||||
// POST /api/v1/epictasks/:id/edges { toId } | { to: "<name in same epic>" }
|
||||
// means "this node depends on `to`".
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/v1/epictasks/:id/edges', async (request, reply) => {
|
||||
const parsed = CreateEdgeSchema.parse(request.body);
|
||||
|
||||
let toId = parsed.toId;
|
||||
if (toId === undefined) {
|
||||
const from = await service.get(request.params.id);
|
||||
const target = await epicTaskRepo.findByNameAndEpic(parsed.to as string, from.epicId);
|
||||
if (target === null) {
|
||||
throw Object.assign(
|
||||
new Error(`EpicTask not found in this epic: ${String(parsed.to)}`),
|
||||
{ statusCode: 404 },
|
||||
);
|
||||
}
|
||||
toId = target.id;
|
||||
}
|
||||
|
||||
const edge = await service.addEdge(request.params.id, toId);
|
||||
reply.code(201);
|
||||
return edge;
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string }; Querystring: { toId?: string } }>(
|
||||
'/api/v1/epictasks/:id/edges',
|
||||
async (request, reply) => {
|
||||
const { toId } = request.query;
|
||||
if (toId === undefined) {
|
||||
throw Object.assign(new Error('toId query parameter is required'), { statusCode: 400 });
|
||||
}
|
||||
await service.removeEdge(request.params.id, toId);
|
||||
reply.code(204);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Roadmap ──
|
||||
// The spider-web view: nodes + per-node completeness + what is startable.
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/epics/:id/roadmap', async (request) => {
|
||||
return service.roadmap(request.params.id);
|
||||
});
|
||||
}
|
||||
235
src/mcpd/src/services/epic-task.service.ts
Normal file
235
src/mcpd/src/services/epic-task.service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { EpicTask, EpicTaskEdge, EpicTaskStatus, Prisma } from '@prisma/client';
|
||||
|
||||
import type { IEpicRepository } from '../repositories/epic.repository.js';
|
||||
import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js';
|
||||
import type { DoneCriterion } from '../validation/epic.schema.js';
|
||||
import {
|
||||
CreateEpicTaskSchema,
|
||||
UpdateEpicTaskSchema,
|
||||
} from '../validation/epic-task.schema.js';
|
||||
|
||||
import { EpicService, type EpicCompleteness } from './epic.service.js';
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
|
||||
/** One roadmap node plus its edges and completeness — the shape the web renders. */
|
||||
export interface RoadmapNode {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: EpicTaskStatus;
|
||||
completeness: EpicCompleteness;
|
||||
/** Prerequisite node ids: this node depends on these. */
|
||||
dependsOn: string[];
|
||||
/** Dependent node ids: these wait on this node. */
|
||||
blocks: string[];
|
||||
}
|
||||
|
||||
export interface Roadmap {
|
||||
epicId: string;
|
||||
nodes: RoadmapNode[];
|
||||
edges: Array<{ from: string; to: string }>;
|
||||
/** Nodes with no unfinished prerequisites — where work can actually start. */
|
||||
ready: string[];
|
||||
}
|
||||
|
||||
function badRequest(message: string): Error {
|
||||
return Object.assign(new Error(message), { statusCode: 400 });
|
||||
}
|
||||
|
||||
function readCriteria(node: { doneCriteria: unknown }): DoneCriterion[] {
|
||||
return (node.doneCriteria ?? []) as DoneCriterion[];
|
||||
}
|
||||
|
||||
export class EpicTaskService {
|
||||
constructor(
|
||||
private readonly repo: IEpicTaskRepository,
|
||||
private readonly epicRepo: IEpicRepository,
|
||||
) {}
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
async list(epicId?: string): Promise<EpicTask[]> {
|
||||
return this.repo.findAll(epicId);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<EpicTask> {
|
||||
const node = await this.repo.findById(id);
|
||||
if (node === null) throw new NotFoundError(`EpicTask not found: ${id}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
async create(input: unknown, ownerId: string): Promise<EpicTask> {
|
||||
const data = CreateEpicTaskSchema.parse(input);
|
||||
|
||||
const epic = await this.epicRepo.findById(data.epicId);
|
||||
if (epic === null) throw new NotFoundError(`Epic not found: ${data.epicId}`);
|
||||
|
||||
const duplicate = await this.repo.findByNameAndEpic(data.name, data.epicId);
|
||||
if (duplicate !== null) {
|
||||
throw Object.assign(
|
||||
new Error(`EpicTask '${data.name}' already exists in epic '${epic.name}'`),
|
||||
{ statusCode: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
return this.repo.create({
|
||||
name: data.name,
|
||||
epicId: data.epicId,
|
||||
ownerId,
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.doneCriteria !== undefined
|
||||
? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, input: unknown): Promise<EpicTask> {
|
||||
const data = UpdateEpicTaskSchema.parse(input);
|
||||
await this.get(id); // 404 if missing
|
||||
|
||||
return this.repo.update(id, {
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.doneCriteria !== undefined
|
||||
? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
...(data.status !== undefined ? { status: data.status as EpicTaskStatus } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.get(id);
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
|
||||
// ── Edges ──
|
||||
|
||||
/**
|
||||
* Add "from depends on to".
|
||||
*
|
||||
* Rejects three things the database cannot (production runs `prisma db push`,
|
||||
* so a CHECK or trigger here would never exist in prod — see the schema test
|
||||
* that documents this boundary):
|
||||
*
|
||||
* 1. self-edges,
|
||||
* 2. edges crossing Epics — a roadmap is one Epic's graph,
|
||||
* 3. cycles, which would make the roadmap unschedulable and hang any
|
||||
* topological walk over it.
|
||||
*/
|
||||
async addEdge(fromId: string, toId: string): Promise<EpicTaskEdge> {
|
||||
if (fromId === toId) {
|
||||
throw badRequest('An epictask cannot depend on itself');
|
||||
}
|
||||
|
||||
const from = await this.get(fromId);
|
||||
const to = await this.repo.findById(toId);
|
||||
if (to === null) throw new NotFoundError(`EpicTask not found: ${toId}`);
|
||||
|
||||
if (from.epicId !== to.epicId) {
|
||||
throw badRequest(
|
||||
'Cannot chain epictasks across different Epics — a roadmap is one Epic\'s graph',
|
||||
);
|
||||
}
|
||||
|
||||
// Adding from→to means "from depends on to". That closes a cycle exactly
|
||||
// when `to` already depends, transitively, on `from`.
|
||||
const path = await this.findDependencyPath(toId, fromId);
|
||||
if (path !== null) {
|
||||
const names = [from.name, ...(await this.namesFor(path))].join(' → ');
|
||||
throw badRequest(`Cycle detected: ${names} → ${from.name}`);
|
||||
}
|
||||
|
||||
const existing = await this.repo.findDependencies(fromId);
|
||||
if (existing.some((e) => e.toId === toId)) {
|
||||
// Already recorded — treat as success so applying a manifest twice is safe.
|
||||
return existing.find((e) => e.toId === toId) as EpicTaskEdge;
|
||||
}
|
||||
|
||||
return this.repo.createEdge(fromId, toId);
|
||||
}
|
||||
|
||||
async removeEdge(fromId: string, toId: string): Promise<void> {
|
||||
await this.get(fromId);
|
||||
await this.repo.deleteEdge(fromId, toId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadth-first walk of `start`'s transitive dependencies, looking for
|
||||
* `target`. Returns the path (excluding `start`) or null.
|
||||
*
|
||||
* The `seen` set does double duty: it keeps this terminating even if the
|
||||
* stored graph already contains a cycle from an earlier bug or a hand-edited
|
||||
* database, rather than spinning forever.
|
||||
*/
|
||||
private async findDependencyPath(start: string, target: string): Promise<string[] | null> {
|
||||
const seen = new Set<string>([start]);
|
||||
const queue: Array<{ id: string; path: string[] }> = [{ id: start, path: [start] }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as { id: string; path: string[] };
|
||||
const edges = await this.repo.findDependencies(current.id);
|
||||
for (const edge of edges) {
|
||||
if (edge.toId === target) return [...current.path, edge.toId];
|
||||
if (seen.has(edge.toId)) continue;
|
||||
seen.add(edge.toId);
|
||||
queue.push({ id: edge.toId, path: [...current.path, edge.toId] });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async namesFor(ids: string[]): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const id of ids) {
|
||||
const node = await this.repo.findById(id);
|
||||
out.push(node?.name ?? id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Roadmap ──
|
||||
|
||||
/**
|
||||
* The whole graph for one Epic: nodes with per-node completeness, the edges
|
||||
* between them, and which nodes are actually startable.
|
||||
*/
|
||||
async roadmap(epicId: string): Promise<Roadmap> {
|
||||
const epic = await this.epicRepo.findById(epicId);
|
||||
if (epic === null) throw new NotFoundError(`Epic not found: ${epicId}`);
|
||||
|
||||
const nodes = await this.repo.findAll(epicId);
|
||||
const edges = await this.repo.findEdges(epicId);
|
||||
|
||||
const dependsOn = new Map<string, string[]>();
|
||||
const blocks = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
dependsOn.set(e.fromId, [...(dependsOn.get(e.fromId) ?? []), e.toId]);
|
||||
blocks.set(e.toId, [...(blocks.get(e.toId) ?? []), e.fromId]);
|
||||
}
|
||||
|
||||
const statusById = new Map(nodes.map((n) => [n.id, n.status]));
|
||||
|
||||
const roadmapNodes: RoadmapNode[] = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
description: n.description,
|
||||
status: n.status,
|
||||
// Reuse the Epic's rule verbatim so a node and its Epic never disagree
|
||||
// about what "met" means.
|
||||
completeness: EpicService.completeness({ doneCriteria: readCriteria(n) } as never),
|
||||
dependsOn: dependsOn.get(n.id) ?? [],
|
||||
blocks: blocks.get(n.id) ?? [],
|
||||
}));
|
||||
|
||||
const ready = roadmapNodes
|
||||
.filter((n) => n.status === 'todo' || n.status === 'in_progress')
|
||||
.filter((n) => n.dependsOn.every((d) => statusById.get(d) === 'done' || statusById.get(d) === 'cancelled'))
|
||||
.map((n) => n.id);
|
||||
|
||||
return {
|
||||
epicId,
|
||||
nodes: roadmapNodes,
|
||||
edges: edges.map((e) => ({ from: e.fromId, to: e.toId })),
|
||||
ready,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
src/mcpd/src/validation/epic-task.schema.ts
Normal file
35
src/mcpd/src/validation/epic-task.schema.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DoneCriterionSchema } from './epic.schema.js';
|
||||
|
||||
const NAME_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
export const CreateEpicTaskSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'),
|
||||
epicId: z.string().min(1),
|
||||
description: z.string().max(2000).optional(),
|
||||
/**
|
||||
* The decomposed subset of the Epic's criteria this node owns. Same shape as
|
||||
* the Epic's, so the roadmap can show per-node completeness with one rule.
|
||||
*/
|
||||
doneCriteria: z.array(DoneCriterionSchema).max(200).optional(),
|
||||
});
|
||||
|
||||
export const UpdateEpicTaskSchema = z.object({
|
||||
description: z.string().max(2000).optional(),
|
||||
doneCriteria: z.array(DoneCriterionSchema).max(200).optional(),
|
||||
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
|
||||
});
|
||||
|
||||
/** `to` is the prerequisite; the node named in the URL depends on it. */
|
||||
export const CreateEdgeSchema = z.object({
|
||||
toId: z.string().min(1).optional(),
|
||||
/** Convenience: name within the same Epic, resolved server-side. */
|
||||
to: z.string().min(1).optional(),
|
||||
}).refine(
|
||||
(d) => (d.toId !== undefined) !== (d.to !== undefined),
|
||||
{ message: 'Pass exactly one of toId or to' },
|
||||
);
|
||||
|
||||
export type CreateEpicTaskInput = z.infer<typeof CreateEpicTaskSchema>;
|
||||
export type UpdateEpicTaskInput = z.infer<typeof UpdateEpicTaskSchema>;
|
||||
250
src/mcpd/tests/services/epic-task-service.test.ts
Normal file
250
src/mcpd/tests/services/epic-task-service.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { Epic, EpicTask, EpicTaskEdge } from '@prisma/client';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import type { IEpicRepository } from '../../src/repositories/epic.repository.js';
|
||||
import type { IEpicTaskRepository } from '../../src/repositories/epic-task.repository.js';
|
||||
import { EpicTaskService } from '../../src/services/epic-task.service.js';
|
||||
|
||||
/**
|
||||
* An in-memory roadmap graph. The schema deliberately does NOT reject cycles
|
||||
* (production runs `prisma db push`, so a trigger would never exist there), so
|
||||
* these tests are the only thing standing between the roadmap and an
|
||||
* unschedulable graph.
|
||||
*/
|
||||
function makeGraph(
|
||||
nodes: Array<{ id: string; name: string; epicId?: string; status?: EpicTask['status']; doneCriteria?: unknown }>,
|
||||
edges: Array<[string, string]> = [],
|
||||
) {
|
||||
const rows: EpicTask[] = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
description: '',
|
||||
epicId: n.epicId ?? 'epic-1',
|
||||
doneCriteria: n.doneCriteria ?? [],
|
||||
status: n.status ?? 'todo',
|
||||
ownerId: 'user-1',
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}) as EpicTask);
|
||||
|
||||
let edgeRows: EpicTaskEdge[] = edges.map(([fromId, toId], i) => ({
|
||||
id: `edge-${String(i)}`,
|
||||
fromId,
|
||||
toId,
|
||||
createdAt: new Date(),
|
||||
}) as EpicTaskEdge);
|
||||
|
||||
const repo = {
|
||||
findAll: vi.fn(async (epicId?: string) =>
|
||||
epicId === undefined ? rows : rows.filter((r) => r.epicId === epicId),
|
||||
),
|
||||
findById: vi.fn(async (id: string) => rows.find((r) => r.id === id) ?? null),
|
||||
findByName: vi.fn(async (name: string) => rows.find((r) => r.name === name) ?? null),
|
||||
findByNameAndEpic: vi.fn(async (name: string, epicId: string) =>
|
||||
rows.find((r) => r.name === name && r.epicId === epicId) ?? null,
|
||||
),
|
||||
create: vi.fn(async () => rows[0] as EpicTask),
|
||||
update: vi.fn(async () => rows[0] as EpicTask),
|
||||
delete: vi.fn(async () => {}),
|
||||
findEdges: vi.fn(async (epicId: string) =>
|
||||
edgeRows.filter((e) => rows.find((r) => r.id === e.fromId)?.epicId === epicId),
|
||||
),
|
||||
findDependencies: vi.fn(async (fromId: string) => edgeRows.filter((e) => e.fromId === fromId)),
|
||||
createEdge: vi.fn(async (fromId: string, toId: string) => {
|
||||
const row = { id: `edge-new`, fromId, toId, createdAt: new Date() } as EpicTaskEdge;
|
||||
edgeRows = [...edgeRows, row];
|
||||
return row;
|
||||
}),
|
||||
deleteEdge: vi.fn(async (fromId: string, toId: string) => {
|
||||
edgeRows = edgeRows.filter((e) => !(e.fromId === fromId && e.toId === toId));
|
||||
}),
|
||||
} as unknown as IEpicTaskRepository & Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
const epicRepo = {
|
||||
findById: vi.fn(async (id: string) => ({ id, name: 'the-car', projectId: 'proj-1' } as Epic)),
|
||||
findByName: vi.fn(async () => ({ id: 'epic-1', name: 'the-car' } as Epic)),
|
||||
findByNameAndProject: vi.fn(async () => ({ id: 'epic-1', name: 'the-car' } as Epic)),
|
||||
} as unknown as IEpicRepository;
|
||||
|
||||
return { repo, epicRepo, service: new EpicTaskService(repo, epicRepo) };
|
||||
}
|
||||
|
||||
const crit = (id: string, met: boolean) => ({ id, text: id, kind: 'outcome' as const, met });
|
||||
|
||||
describe('EpicTaskService.addEdge — graph integrity', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('records a plain dependency', async () => {
|
||||
const { service, repo } = makeGraph([
|
||||
{ id: 'a', name: 'wheels' },
|
||||
{ id: 'b', name: 'chassis' },
|
||||
]);
|
||||
await service.addEdge('a', 'b');
|
||||
expect(repo.createEdge).toHaveBeenCalledWith('a', 'b');
|
||||
});
|
||||
|
||||
it('rejects a self-edge', async () => {
|
||||
const { service } = makeGraph([{ id: 'a', name: 'wheels' }]);
|
||||
await expect(service.addEdge('a', 'a')).rejects.toThrow(/cannot depend on itself/);
|
||||
});
|
||||
|
||||
it('rejects an edge crossing Epics — a roadmap is one Epic\'s graph', async () => {
|
||||
const { service } = makeGraph([
|
||||
{ id: 'a', name: 'wheels', epicId: 'epic-1' },
|
||||
{ id: 'b', name: 'other', epicId: 'epic-2' },
|
||||
]);
|
||||
await expect(service.addEdge('a', 'b')).rejects.toThrow(/across different Epics/);
|
||||
});
|
||||
|
||||
it('rejects a direct 2-cycle', async () => {
|
||||
const { service } = makeGraph(
|
||||
[{ id: 'a', name: 'wheels' }, { id: 'b', name: 'chassis' }],
|
||||
[['a', 'b']],
|
||||
);
|
||||
// a already depends on b; making b depend on a closes the loop.
|
||||
await expect(service.addEdge('b', 'a')).rejects.toThrow(/Cycle detected/);
|
||||
});
|
||||
|
||||
it('rejects a long transitive cycle and names the path', async () => {
|
||||
const { service } = makeGraph(
|
||||
[
|
||||
{ id: 'a', name: 'aa' }, { id: 'b', name: 'bb' },
|
||||
{ id: 'c', name: 'cc' }, { id: 'd', name: 'dd' },
|
||||
],
|
||||
[['a', 'b'], ['b', 'c'], ['c', 'd']],
|
||||
);
|
||||
// a→b→c→d already; adding d→a would close it.
|
||||
await expect(service.addEdge('d', 'a')).rejects.toThrow(/Cycle detected: dd/);
|
||||
});
|
||||
|
||||
it('allows a diamond — shared prerequisites are not cycles', async () => {
|
||||
const { service, repo } = makeGraph(
|
||||
[
|
||||
{ id: 'top', name: 'top' }, { id: 'l', name: 'left' },
|
||||
{ id: 'r', name: 'right' }, { id: 'base', name: 'base' },
|
||||
],
|
||||
[['top', 'l'], ['top', 'r'], ['l', 'base']],
|
||||
);
|
||||
await service.addEdge('r', 'base');
|
||||
expect(repo.createEdge).toHaveBeenCalledWith('r', 'base');
|
||||
});
|
||||
|
||||
it('is idempotent — re-adding an existing edge does not duplicate it', async () => {
|
||||
const { service, repo } = makeGraph(
|
||||
[{ id: 'a', name: 'aa' }, { id: 'b', name: 'bb' }],
|
||||
[['a', 'b']],
|
||||
);
|
||||
const edge = await service.addEdge('a', 'b');
|
||||
expect(edge.toId).toBe('b');
|
||||
expect(repo.createEdge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('terminates even if the stored graph already contains a cycle', async () => {
|
||||
// A hand-edited database or an earlier bug could leave one behind; the
|
||||
// traversal must not spin forever on it.
|
||||
const { service } = makeGraph(
|
||||
[{ id: 'a', name: 'aa' }, { id: 'b', name: 'bb' }, { id: 'c', name: 'cc' }],
|
||||
[['a', 'b'], ['b', 'a']],
|
||||
);
|
||||
await expect(service.addEdge('c', 'a')).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('404s on a missing prerequisite node', async () => {
|
||||
const { service } = makeGraph([{ id: 'a', name: 'aa' }]);
|
||||
await expect(service.addEdge('a', 'ghost')).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpicTaskService.roadmap', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('reports both edge directions per node', async () => {
|
||||
const { service } = makeGraph(
|
||||
[{ id: 'a', name: 'aa' }, { id: 'b', name: 'bb' }],
|
||||
[['a', 'b']],
|
||||
);
|
||||
const map = await service.roadmap('epic-1');
|
||||
const a = map.nodes.find((n) => n.id === 'a');
|
||||
const b = map.nodes.find((n) => n.id === 'b');
|
||||
expect(a?.dependsOn).toEqual(['b']);
|
||||
expect(b?.blocks).toEqual(['a']);
|
||||
expect(map.edges).toEqual([{ from: 'a', to: 'b' }]);
|
||||
});
|
||||
|
||||
it('computes per-node completeness with the Epic\'s own rule', async () => {
|
||||
const { service } = makeGraph([
|
||||
{ id: 'a', name: 'aa', doneCriteria: [crit('x', true), crit('y', false)] },
|
||||
]);
|
||||
const map = await service.roadmap('epic-1');
|
||||
expect(map.nodes[0]?.completeness).toMatchObject({ total: 2, met: 1, percent: 50 });
|
||||
});
|
||||
|
||||
it('marks only nodes with satisfied prerequisites as ready', async () => {
|
||||
const { service } = makeGraph(
|
||||
[
|
||||
{ id: 'base', name: 'base', status: 'todo' },
|
||||
{ id: 'mid', name: 'mid', status: 'todo' },
|
||||
],
|
||||
[['mid', 'base']],
|
||||
);
|
||||
const map = await service.roadmap('epic-1');
|
||||
expect(map.ready).toEqual(['base']);
|
||||
});
|
||||
|
||||
it('treats a cancelled prerequisite as satisfied — it is not coming', async () => {
|
||||
const { service } = makeGraph(
|
||||
[
|
||||
{ id: 'base', name: 'base', status: 'cancelled' },
|
||||
{ id: 'mid', name: 'mid', status: 'todo' },
|
||||
],
|
||||
[['mid', 'base']],
|
||||
);
|
||||
const map = await service.roadmap('epic-1');
|
||||
expect(map.ready).toContain('mid');
|
||||
});
|
||||
|
||||
it('excludes finished nodes from ready', async () => {
|
||||
const { service } = makeGraph([{ id: 'a', name: 'aa', status: 'done' }]);
|
||||
const map = await service.roadmap('epic-1');
|
||||
expect(map.ready).toEqual([]);
|
||||
});
|
||||
|
||||
it('404s on an unknown epic', async () => {
|
||||
const { service, epicRepo } = makeGraph([]);
|
||||
(epicRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await expect(service.roadmap('ghost')).rejects.toThrow(/Epic not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpicTaskService CRUD', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('409s on a duplicate name within the epic', async () => {
|
||||
const { service } = makeGraph([{ id: 'a', name: 'wheels' }]);
|
||||
await expect(
|
||||
service.create({ name: 'wheels', epicId: 'epic-1' }, 'user-1'),
|
||||
).rejects.toThrow(/already exists in epic/);
|
||||
});
|
||||
|
||||
it('404s when the epic does not exist', async () => {
|
||||
const { service, epicRepo } = makeGraph([]);
|
||||
(epicRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
await expect(service.create({ name: 'x', epicId: 'ghost' }, 'user-1')).rejects.toThrow(
|
||||
/Epic not found/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a non-kebab name', async () => {
|
||||
const { service } = makeGraph([]);
|
||||
await expect(service.create({ name: 'Wheels!', epicId: 'epic-1' }, 'user-1')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('accepts every node status', async () => {
|
||||
const { service, repo } = makeGraph([{ id: 'a', name: 'aa' }]);
|
||||
for (const status of ['todo', 'in_progress', 'done', 'cancelled'] as const) {
|
||||
await service.update('a', { status });
|
||||
expect(repo.update).toHaveBeenCalledWith('a', expect.objectContaining({ status }));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user