agentic-teams: epics, tasks, git forges, agent authority #139
@@ -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