feat(db): agentic-teams data model — epics, work items, repos, membership
Adds the foundation entities from docs/agentic-teams.md §5/§7: a versioned vision layer (Epic -> EpicTask -> Task), git repositories attached to projects, agents as many-to-many project members, and the ephemeral session that IS an agent's workspace. 9 tables + 5 enums, purely additive — no drops and no column changes on existing tables, since every new relation is held on the new side. Shape notes: - EpicTask chaining uses an explicit EpicTaskEdge table rather than a self-referencing array, so the roadmap graph walks in both directions and edges can carry a type later. - TaskVerdict is commit-pinned and immutable: "QA verified 3f2ab" stays reproducible, which is what lets later coverage claims be audited rather than believed. TaskEvent is the append-only transition log. - Task survives losing its repo and its assignee (both SetNull) — a work item outlives whoever happened to be working it — but cascades from its EpicTask. - CodeRepo, not Repository: `repository.repository.ts` next to 24 other *.repository.ts data-access files would be actively confusing. CLI resource is `repos`, with repo/repository/repositories aliased onto it. - AgentProject.llmId exists so the brain-ownership question (docs/agentic-teams.md §15 Q1) can be settled either way without a migration. - Agent.projectId is retained and coexists with AgentProject for one release so findByProjectId / prepareContext keep working. Note this partially reverses 20260223120000_remove_project_members — deliberately, and only for agents; human membership stays RBAC-driven. No CHECK constraints anywhere, on purpose: deploy/entrypoint.sh provisions with `prisma db push`, so hand-written migration SQL never runs in production. The migration file is kept for local `migrate dev` parity and as a readable record, and says so in its header. Invariants that cannot be expressed in the schema (EpicTaskEdge cycle rejection) belong to the service layer; a schema test documents that boundary rather than pretending the database enforces it. Tests: 23 schema tests covering defaults, per-project name scoping, cascade vs SetNull on every FK, all 12 lifecycle states, and the GC-sweep predicate. clearAllTables extended for all 9 models. Verified locally against postgres:16-alpine on :5433 — db suite 128 passed (baseline was 105; the same 4 pre-existing Secret_backendId_fkey failures remain, unrelated). Workspace 2291 passed, build clean, lint unchanged at the repo's pre-existing 869. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
428
src/db/tests/agentic-teams-schema.test.ts
Normal file
428
src/db/tests/agentic-teams-schema.test.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
|
||||
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
|
||||
|
||||
/**
|
||||
* Schema-level cover for the agentic-teams foundation: defaults, the uniqueness
|
||||
* rules that make names addressable, and the cascade/SetNull behaviour that
|
||||
* decides what survives a delete.
|
||||
*
|
||||
* Cascade rules are load-bearing here, not incidental — deleting a Project must
|
||||
* take its Epics and work items with it, but deleting the *repo* or the
|
||||
* *assignee* must leave the Task standing, because a task outlives the agent
|
||||
* that happened to be working it.
|
||||
*/
|
||||
describe('Agentic teams schema', () => {
|
||||
let prisma: PrismaClient;
|
||||
let seq = 0;
|
||||
|
||||
const uniq = (p: string): string => `${p}-${Date.now()}-${seq++}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = await setupTestDb();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAllTables(prisma);
|
||||
});
|
||||
|
||||
async function createUser() {
|
||||
return prisma.user.create({
|
||||
data: { email: `${uniq('u')}@example.com`, name: 'Test', passwordHash: '!locked', role: 'USER' },
|
||||
});
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const user = await createUser();
|
||||
const project = await prisma.project.create({ data: { name: uniq('proj'), ownerId: user.id } });
|
||||
return { project, user };
|
||||
}
|
||||
|
||||
async function createLlm() {
|
||||
return prisma.llm.create({ data: { name: uniq('llm'), type: 'openai', model: 'test' } });
|
||||
}
|
||||
|
||||
async function createAgent() {
|
||||
const user = await createUser();
|
||||
const llm = await createLlm();
|
||||
return prisma.agent.create({ data: { name: uniq('agent'), llmId: llm.id, ownerId: user.id } });
|
||||
}
|
||||
|
||||
async function createEpic(projectId: string, ownerId: string) {
|
||||
return prisma.epic.create({
|
||||
data: { name: uniq('epic'), content: 'the vision', projectId, ownerId },
|
||||
});
|
||||
}
|
||||
|
||||
async function createEpicTask(epicId: string, ownerId: string) {
|
||||
return prisma.epicTask.create({ data: { name: uniq('et'), epicId, ownerId } });
|
||||
}
|
||||
|
||||
async function createTask(epicTaskId: string, ownerId: string, extra: Record<string, unknown> = {}) {
|
||||
return prisma.task.create({
|
||||
data: { name: uniq('task'), title: 'Do the thing', epicTaskId, ownerId, ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
describe('Epic', () => {
|
||||
it('applies documented defaults', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
|
||||
expect(epic.status).toBe('open');
|
||||
expect(epic.semver).toBe('0.1.0');
|
||||
expect(epic.version).toBe(1);
|
||||
expect(epic.currentRevisionId).toBeNull();
|
||||
expect(epic.description).toBe('');
|
||||
expect(epic.doneCriteria).toEqual([]);
|
||||
expect(epic.acceptedTradeoffs).toEqual([]);
|
||||
});
|
||||
|
||||
it('scopes name uniqueness per project, not globally', async () => {
|
||||
const a = await createProject();
|
||||
const b = await createProject();
|
||||
const name = uniq('shared-epic');
|
||||
|
||||
await prisma.epic.create({ data: { name, content: 'x', projectId: a.project.id, ownerId: a.user.id } });
|
||||
// Same name in a different project is fine...
|
||||
await expect(
|
||||
prisma.epic.create({ data: { name, content: 'x', projectId: b.project.id, ownerId: b.user.id } }),
|
||||
).resolves.toBeDefined();
|
||||
// ...but a duplicate within one project is not.
|
||||
await expect(
|
||||
prisma.epic.create({ data: { name, content: 'y', projectId: a.project.id, ownerId: a.user.id } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('records accepted tradeoffs as structured JSON', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const tradeoff = { what: 'no dark mode', why: 'out of budget', who: 'michal', deScopedBy: 'michal' };
|
||||
|
||||
const updated = await prisma.epic.update({
|
||||
where: { id: epic.id },
|
||||
data: { acceptedTradeoffs: [tradeoff], status: 'delivered_with_tradeoffs' },
|
||||
});
|
||||
|
||||
expect(updated.acceptedTradeoffs).toEqual([tradeoff]);
|
||||
expect(updated.status).toBe('delivered_with_tradeoffs');
|
||||
});
|
||||
|
||||
it('cascades from Project', async () => {
|
||||
const { project, user } = await createProject();
|
||||
await createEpic(project.id, user.id);
|
||||
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
|
||||
expect(await prisma.epic.count({ where: { projectId: project.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpicTask and the roadmap graph', () => {
|
||||
it('chains nodes through an explicit edge table', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const foundation = await createEpicTask(epic.id, user.id);
|
||||
const dependent = await createEpicTask(epic.id, user.id);
|
||||
|
||||
await prisma.epicTaskEdge.create({ data: { fromId: dependent.id, toId: foundation.id } });
|
||||
|
||||
const loaded = await prisma.epicTask.findUnique({
|
||||
where: { id: dependent.id },
|
||||
include: { dependsOn: true, blocks: true },
|
||||
});
|
||||
expect(loaded?.dependsOn).toHaveLength(1);
|
||||
expect(loaded?.dependsOn[0]?.toId).toBe(foundation.id);
|
||||
|
||||
// ...and the graph is walkable from the other side too.
|
||||
const reverse = await prisma.epicTask.findUnique({
|
||||
where: { id: foundation.id },
|
||||
include: { blocks: true },
|
||||
});
|
||||
expect(reverse?.blocks).toHaveLength(1);
|
||||
expect(reverse?.blocks[0]?.fromId).toBe(dependent.id);
|
||||
});
|
||||
|
||||
it('rejects a duplicate edge between the same pair', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const a = await createEpicTask(epic.id, user.id);
|
||||
const b = await createEpicTask(epic.id, user.id);
|
||||
|
||||
await prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } });
|
||||
await expect(prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does NOT reject a cycle — that is the service layer\'s job', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const a = await createEpicTask(epic.id, user.id);
|
||||
const b = await createEpicTask(epic.id, user.id);
|
||||
|
||||
await prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } });
|
||||
// Documents the boundary: production runs `prisma db push`, so a CHECK or
|
||||
// trigger here would never exist in prod. Cycle rejection lives in the
|
||||
// EpicTask service and is tested there.
|
||||
await expect(prisma.epicTaskEdge.create({ data: { fromId: b.id, toId: a.id } })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('cascades edges when a node is deleted', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const a = await createEpicTask(epic.id, user.id);
|
||||
const b = await createEpicTask(epic.id, user.id);
|
||||
await prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } });
|
||||
|
||||
await prisma.epicTask.delete({ where: { id: a.id } });
|
||||
|
||||
expect(await prisma.epicTaskEdge.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task', () => {
|
||||
it('applies documented defaults', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
|
||||
expect(task.state).toBe('todo');
|
||||
expect(task.risk).toBe('normal');
|
||||
expect(task.assigneeId).toBeNull();
|
||||
expect(task.repoId).toBeNull();
|
||||
expect(task.branch).toBeNull();
|
||||
expect(task.prUrl).toBeNull();
|
||||
expect(task.prNumber).toBeNull();
|
||||
expect(task.reviewNote).toBeNull();
|
||||
expect(task.version).toBe(1);
|
||||
});
|
||||
|
||||
it('accepts every lifecycle state', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
|
||||
const states = [
|
||||
'in_progress', 'building', 'self_testing', 'qa_review', 'security_review',
|
||||
'sre_review', 'done', 'awaiting_review', 'merged', 'reopened', 'cancelled',
|
||||
] as const;
|
||||
|
||||
for (const state of states) {
|
||||
const updated = await prisma.task.update({ where: { id: task.id }, data: { state } });
|
||||
expect(updated.state).toBe(state);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the task when its assignee agent is deleted', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const agent = await createAgent();
|
||||
const task = await createTask(epicTask.id, user.id, { assigneeId: agent.id });
|
||||
|
||||
await prisma.agent.delete({ where: { id: agent.id } });
|
||||
|
||||
// A task outlives whoever happened to be working it.
|
||||
const after = await prisma.task.findUnique({ where: { id: task.id } });
|
||||
expect(after).not.toBeNull();
|
||||
expect(after?.assigneeId).toBeNull();
|
||||
});
|
||||
|
||||
it('cascades verdicts and events, and keeps them commit-pinned', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
|
||||
await prisma.taskVerdict.create({
|
||||
data: {
|
||||
taskId: task.id, stage: 'qa_review', commit: '3f2ab19', passed: false,
|
||||
summary: 'missing coverage on the error path',
|
||||
findings: [{ severity: 'major', message: 'no test for 500', path: 'src/a.ts' }],
|
||||
},
|
||||
});
|
||||
await prisma.taskEvent.create({
|
||||
data: { taskId: task.id, fromState: 'qa_review', toState: 'in_progress', actorType: 'agent' },
|
||||
});
|
||||
|
||||
const verdict = await prisma.taskVerdict.findFirst({ where: { taskId: task.id } });
|
||||
expect(verdict?.commit).toBe('3f2ab19');
|
||||
expect(verdict?.passed).toBe(false);
|
||||
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
expect(await prisma.taskVerdict.count()).toBe(0);
|
||||
expect(await prisma.taskEvent.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('allows a TaskEvent with no fromState — the first transition', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
|
||||
const event = await prisma.taskEvent.create({
|
||||
data: { taskId: task.id, toState: 'todo', actorType: 'system' },
|
||||
});
|
||||
expect(event.fromState).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodeRepo', () => {
|
||||
it('applies documented defaults', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const repo = await prisma.codeRepo.create({
|
||||
data: { name: uniq('repo'), projectId: project.id, url: 'https://git.example/x.git', ownerId: user.id },
|
||||
});
|
||||
|
||||
expect(repo.defaultBranch).toBe('main');
|
||||
expect(repo.provider).toBe('gitea');
|
||||
expect(repo.path).toBe('');
|
||||
expect(repo.credSecretId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the task when its repo is deleted', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const repo = await prisma.codeRepo.create({
|
||||
data: { name: uniq('repo'), projectId: project.id, url: 'https://git.example/x.git', ownerId: user.id },
|
||||
});
|
||||
const task = await createTask(epicTask.id, user.id, { repoId: repo.id });
|
||||
|
||||
await prisma.codeRepo.delete({ where: { id: repo.id } });
|
||||
|
||||
const after = await prisma.task.findUnique({ where: { id: task.id } });
|
||||
expect(after?.repoId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentProject membership', () => {
|
||||
it('lets one agent join many projects with different roles', async () => {
|
||||
const agent = await createAgent();
|
||||
const a = await createProject();
|
||||
const b = await createProject();
|
||||
|
||||
await prisma.agentProject.create({ data: { agentId: agent.id, projectId: a.project.id, role: 'developer' } });
|
||||
await prisma.agentProject.create({ data: { agentId: agent.id, projectId: b.project.id, role: 'security' } });
|
||||
|
||||
const memberships = await prisma.agentProject.findMany({ where: { agentId: agent.id } });
|
||||
expect(memberships).toHaveLength(2);
|
||||
expect(memberships.map((m) => m.role).sort()).toEqual(['developer', 'security']);
|
||||
});
|
||||
|
||||
it('defaults the role to developer and leaves the brain override unset', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project } = await createProject();
|
||||
|
||||
const membership = await prisma.agentProject.create({
|
||||
data: { agentId: agent.id, projectId: project.id },
|
||||
});
|
||||
expect(membership.role).toBe('developer');
|
||||
expect(membership.llmId).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects joining the same project twice', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project } = await createProject();
|
||||
|
||||
await prisma.agentProject.create({ data: { agentId: agent.id, projectId: project.id } });
|
||||
await expect(
|
||||
prisma.agentProject.create({ data: { agentId: agent.id, projectId: project.id } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('drops the membership but keeps the agent when a project goes away', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project } = await createProject();
|
||||
await prisma.agentProject.create({ data: { agentId: agent.id, projectId: project.id } });
|
||||
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
|
||||
expect(await prisma.agentProject.count({ where: { agentId: agent.id } })).toBe(0);
|
||||
expect(await prisma.agent.findUnique({ where: { id: agent.id } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears only the brain override when its Llm is deleted', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project } = await createProject();
|
||||
const llm = await createLlm();
|
||||
const membership = await prisma.agentProject.create({
|
||||
data: { agentId: agent.id, projectId: project.id, llmId: llm.id },
|
||||
});
|
||||
|
||||
await prisma.llm.delete({ where: { id: llm.id } });
|
||||
|
||||
const after = await prisma.agentProject.findUnique({ where: { id: membership.id } });
|
||||
expect(after).not.toBeNull();
|
||||
expect(after?.llmId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentSession', () => {
|
||||
it('applies documented defaults', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project, user } = await createProject();
|
||||
|
||||
const session = await prisma.agentSession.create({
|
||||
data: {
|
||||
agentId: agent.id, projectId: project.id, ownerId: user.id,
|
||||
image: 'mcpctl-agent-runner:latest', expiresAt: new Date(Date.now() + 3600_000),
|
||||
},
|
||||
});
|
||||
|
||||
expect(session.state).toBe('pending');
|
||||
expect(session.containerId).toBeNull();
|
||||
expect(session.taskId).toBeNull();
|
||||
expect(session.endedAt).toBeNull();
|
||||
expect(session.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it('survives its task being deleted — the pod record outlives the work item', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
const session = await prisma.agentSession.create({
|
||||
data: {
|
||||
agentId: agent.id, projectId: project.id, taskId: task.id, ownerId: user.id,
|
||||
image: 'x:latest', expiresAt: new Date(Date.now() + 3600_000),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
|
||||
const after = await prisma.agentSession.findUnique({ where: { id: session.id } });
|
||||
expect(after).not.toBeNull();
|
||||
expect(after?.taskId).toBeNull();
|
||||
});
|
||||
|
||||
it('finds expired sessions for the GC sweep', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project, user } = await createProject();
|
||||
await prisma.agentSession.create({
|
||||
data: {
|
||||
agentId: agent.id, projectId: project.id, ownerId: user.id, image: 'x:latest',
|
||||
state: 'running', expiresAt: new Date(Date.now() - 1000),
|
||||
},
|
||||
});
|
||||
await prisma.agentSession.create({
|
||||
data: {
|
||||
agentId: agent.id, projectId: project.id, ownerId: user.id, image: 'x:latest',
|
||||
state: 'running', expiresAt: new Date(Date.now() + 3600_000),
|
||||
},
|
||||
});
|
||||
|
||||
const expired = await prisma.agentSession.findMany({
|
||||
where: { state: { in: ['pending', 'provisioning', 'running'] }, expiresAt: { lt: new Date() } },
|
||||
});
|
||||
expect(expired).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,18 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
|
||||
await client.resourceProposal.deleteMany();
|
||||
// Revisions have no FK (soft FK); always orphans without explicit clear.
|
||||
await client.resourceRevision.deleteMany();
|
||||
// Agentic teams — leaves first, then up the chain. Most of these cascade from
|
||||
// Project/Agent/User, but deleting them explicitly keeps the order obvious and
|
||||
// stops a future nullable FK from silently leaking rows between tests.
|
||||
await client.agentSession.deleteMany();
|
||||
await client.taskEvent.deleteMany();
|
||||
await client.taskVerdict.deleteMany();
|
||||
await client.task.deleteMany();
|
||||
await client.epicTaskEdge.deleteMany();
|
||||
await client.epicTask.deleteMany();
|
||||
await client.epic.deleteMany();
|
||||
await client.codeRepo.deleteMany();
|
||||
await client.agentProject.deleteMany();
|
||||
await client.agent.deleteMany();
|
||||
await client.llm.deleteMany();
|
||||
await client.mcpInstance.deleteMany();
|
||||
|
||||
Reference in New Issue
Block a user