agentic-teams: epics, tasks, git forges, agent authority #139

Merged
michal merged 36 commits from feat/agentic-teams into main 2026-10-09 14:47:34 +00:00
7 changed files with 768 additions and 6 deletions
Showing only changes of commit 7f64316d59 - Show all commits

View File

@@ -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"
]

View File

@@ -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);

View 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 } });
}
}

View 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);
});
}

View 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,
};
}
}

View 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>;

View 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 }));
}
});
});