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:
Michal
2026-08-08 10:30:02 +01:00
parent a186d95a85
commit fceb3cfb95
5 changed files with 1125 additions and 31 deletions

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

View File

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