diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 5307eda..6978ece 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -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" ] diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 91e4955..89e0f96 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -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 { // 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 { llms: llmRepo, agents: agentRepo, epics: epicRepo, + epictasks: epicTaskRepo, // Resolve user CUID → email so name-scoped `edit:users:` bindings // match on PUT /api/v1/users/:id/password (admin reset). users: { findById: async (id: string) => { @@ -559,6 +565,7 @@ async function main(): Promise { // 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 { 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); diff --git a/src/mcpd/src/repositories/epic-task.repository.ts b/src/mcpd/src/repositories/epic-task.repository.ts new file mode 100644 index 0000000..aefc2f9 --- /dev/null +++ b/src/mcpd/src/repositories/epic-task.repository.ts @@ -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; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameAndEpic(name: string, epicId: string): Promise; + create(data: EpicTaskCreateInput): Promise; + update(id: string, data: EpicTaskUpdateInput): Promise; + delete(id: string): Promise; + + // ── Edges ── + findEdges(epicId: string): Promise; + findDependencies(fromId: string): Promise; + createEdge(fromId: string, toId: string): Promise; + deleteEdge(fromId: string, toId: string): Promise; +} + +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 { + return this.prisma.epicTask.findMany({ + ...(epicId !== undefined ? { where: { epicId } } : {}), + include: NODE_INCLUDE, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + 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 { + return this.prisma.epicTask.findFirst({ where: { name }, include: NODE_INCLUDE }); + } + + async findByNameAndEpic(name: string, epicId: string): Promise { + return this.prisma.epicTask.findUnique({ + where: { name_epicId: { name, epicId } }, + include: NODE_INCLUDE, + }); + } + + async create(data: EpicTaskCreateInput): Promise { + return this.prisma.epicTask.create({ data, include: NODE_INCLUDE }); + } + + async update(id: string, data: EpicTaskUpdateInput): Promise { + return this.prisma.epicTask.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + include: NODE_INCLUDE, + }); + } + + async delete(id: string): Promise { + await this.prisma.epicTask.delete({ where: { id } }); + } + + // ── Edges ── + + /** Every edge whose dependent node belongs to this Epic. */ + async findEdges(epicId: string): Promise { + return this.prisma.epicTaskEdge.findMany({ where: { from: { epicId } } }); + } + + async findDependencies(fromId: string): Promise { + return this.prisma.epicTaskEdge.findMany({ where: { fromId } }); + } + + async createEdge(fromId: string, toId: string): Promise { + return this.prisma.epicTaskEdge.create({ data: { fromId, toId } }); + } + + async deleteEdge(fromId: string, toId: string): Promise { + await this.prisma.epicTaskEdge.deleteMany({ where: { fromId, toId } }); + } +} diff --git a/src/mcpd/src/routes/epictasks.ts b/src/mcpd/src/routes/epictasks.ts new file mode 100644 index 0000000..de96f00 --- /dev/null +++ b/src/mcpd/src/routes/epictasks.ts @@ -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=` (+ optional `?project=`) to an epic id. */ + async function resolveEpicId(epicName: string, projectName?: string): Promise { + 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; + const resolved: Record = { ...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: "" } + // 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); + }); +} diff --git a/src/mcpd/src/services/epic-task.service.ts b/src/mcpd/src/services/epic-task.service.ts new file mode 100644 index 0000000..eba9a45 --- /dev/null +++ b/src/mcpd/src/services/epic-task.service.ts @@ -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 { + return this.repo.findAll(epicId); + } + + async get(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const seen = new Set([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 { + 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 { + 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(); + const blocks = new Map(); + 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, + }; + } +} diff --git a/src/mcpd/src/validation/epic-task.schema.ts b/src/mcpd/src/validation/epic-task.schema.ts new file mode 100644 index 0000000..0c46f12 --- /dev/null +++ b/src/mcpd/src/validation/epic-task.schema.ts @@ -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; +export type UpdateEpicTaskInput = z.infer; diff --git a/src/mcpd/tests/services/epic-task-service.test.ts b/src/mcpd/tests/services/epic-task-service.test.ts new file mode 100644 index 0000000..585e926 --- /dev/null +++ b/src/mcpd/tests/services/epic-task-service.test.ts @@ -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>; + + 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).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).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 })); + } + }); +});