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

@@ -2558,9 +2558,9 @@
"testStrategy": "Unit tests in src/mcpd/tests/validation/: verify normalizeResource('task') returns 'tasks', normalizeResource('inference-task') returns 'inference-tasks', normalizeResource('et') returns 'epictasks'. Test that RBAC_RESOURCES includes all new entries.",
"priority": "high",
"dependencies": [],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T09:22:12.130Z"
},
{
"id": "116",
@@ -2572,9 +2572,9 @@
"dependencies": [
"115"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T09:22:12.140Z"
},
{
"id": "117",
@@ -2584,9 +2584,9 @@
"testStrategy": "Schema tests in src/db/tests/: create Epic, verify unique constraint on (name, projectId), test cascade delete from Project, test EpicTask creation with Epic reference, test EpicTaskEdge uniqueness.",
"priority": "high",
"dependencies": [],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T09:29:43.358Z"
},
{
"id": "118",
@@ -2598,9 +2598,9 @@
"dependencies": [
"117"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T09:29:43.369Z"
},
{
"id": "119",
@@ -2610,9 +2610,9 @@
"testStrategy": "Schema tests: create CodeRepo linked to Project, verify unique constraint on (name, projectId), test cascade delete from Project, test SetNull behavior when Secret is deleted.",
"priority": "high",
"dependencies": [],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T09:29:43.377Z"
},
{
"id": "120",
@@ -2622,9 +2622,9 @@
"testStrategy": "Schema tests: create AgentProject junction, verify unique constraint prevents duplicate membership, test cascade on both Agent and Project delete, test SetNull on Llm delete.",
"priority": "high",
"dependencies": [],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T09:29:43.384Z"
},
{
"id": "121",
@@ -2637,9 +2637,9 @@
"118",
"120"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T09:29:43.392Z"
},
{
"id": "122",
@@ -2655,9 +2655,9 @@
"120",
"121"
],
"status": "pending",
"status": "done",
"subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z"
"updatedAt": "2026-08-08T09:29:43.398Z"
},
{
"id": "123",
@@ -3250,9 +3250,13 @@
}
],
"metadata": {
"created": "2026-08-08T08:54:31.861Z",
"updated": "2026-08-08T08:54:31.861Z",
"description": "Tasks for master context"
"version": "1.0.0",
"lastModified": "2026-08-08T09:29:43.398Z",
"taskCount": 162,
"completedCount": 104,
"tags": [
"master"
]
}
}
}

View File

@@ -0,0 +1,313 @@
-- Agentic Teams foundation: vision layer, work items, repos, membership, sessions.
--
-- Purely additive: 9 tables + 5 enums, no drops, no column changes on existing
-- tables (the new relations are all held on the new side).
--
-- HEADS UP: deploy/entrypoint.sh provisions with `prisma db push`, not
-- `prisma migrate deploy`, so this file does NOT run in production -- `db push`
-- derives the same DDL from schema.prisma. It is kept for local `migrate dev`
-- parity and as a readable record of the change. Any invariant that matters is
-- expressed in schema.prisma or enforced in the service layer, never here.
-- CreateEnum
CREATE TYPE "EpicStatus" AS ENUM ('open', 'delivered', 'delivered_with_tradeoffs', 'cancelled');
-- CreateEnum
CREATE TYPE "EpicTaskStatus" AS ENUM ('todo', 'in_progress', 'done', 'cancelled');
-- CreateEnum
CREATE TYPE "TaskState" AS ENUM ('todo', 'in_progress', 'building', 'self_testing', 'qa_review', 'security_review', 'sre_review', 'done', 'awaiting_review', 'merged', 'reopened', 'cancelled');
-- CreateEnum
CREATE TYPE "TaskRisk" AS ENUM ('trivial', 'normal', 'high');
-- CreateEnum
CREATE TYPE "SessionState" AS ENUM ('pending', 'provisioning', 'running', 'ended', 'failed');
-- CreateTable
CREATE TABLE "Epic" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"content" TEXT NOT NULL,
"doneCriteria" JSONB NOT NULL DEFAULT '[]',
"acceptedTradeoffs" JSONB NOT NULL DEFAULT '[]',
"projectId" TEXT NOT NULL,
"status" "EpicStatus" NOT NULL DEFAULT 'open',
"semver" TEXT NOT NULL DEFAULT '0.1.0',
"currentRevisionId" TEXT,
"ownerId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Epic_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EpicTask" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"epicId" TEXT NOT NULL,
"doneCriteria" JSONB NOT NULL DEFAULT '[]',
"status" "EpicTaskStatus" NOT NULL DEFAULT 'todo',
"ownerId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EpicTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EpicTaskEdge" (
"id" TEXT NOT NULL,
"fromId" TEXT NOT NULL,
"toId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EpicTaskEdge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"epicTaskId" TEXT NOT NULL,
"repoId" TEXT,
"assigneeId" TEXT,
"state" "TaskState" NOT NULL DEFAULT 'todo',
"risk" "TaskRisk" NOT NULL DEFAULT 'normal',
"branch" TEXT,
"headCommit" TEXT,
"prUrl" TEXT,
"prNumber" INTEGER,
"reviewNote" TEXT,
"ownerId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TaskVerdict" (
"id" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"stage" "TaskState" NOT NULL,
"agentId" TEXT,
"commit" TEXT NOT NULL,
"passed" BOOLEAN NOT NULL,
"summary" TEXT NOT NULL,
"findings" JSONB NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TaskVerdict_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TaskEvent" (
"id" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"fromState" "TaskState",
"toState" "TaskState" NOT NULL,
"actorType" TEXT NOT NULL,
"actorId" TEXT,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TaskEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CodeRepo" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"defaultBranch" TEXT NOT NULL DEFAULT 'main',
"path" TEXT NOT NULL DEFAULT '',
"provider" TEXT NOT NULL DEFAULT 'gitea',
"credSecretId" TEXT,
"credSecretKey" TEXT,
"ownerId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CodeRepo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentProject" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'developer',
"llmId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AgentProject_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentSession" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"taskId" TEXT,
"containerId" TEXT,
"state" "SessionState" NOT NULL DEFAULT 'pending',
"image" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"lastError" TEXT,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"endedAt" TIMESTAMP(3),
CONSTRAINT "AgentSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Epic_projectId_idx" ON "Epic"("projectId");
-- CreateIndex
CREATE INDEX "Epic_name_idx" ON "Epic"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Epic_name_projectId_key" ON "Epic"("name", "projectId");
-- CreateIndex
CREATE INDEX "EpicTask_epicId_idx" ON "EpicTask"("epicId");
-- CreateIndex
CREATE UNIQUE INDEX "EpicTask_name_epicId_key" ON "EpicTask"("name", "epicId");
-- CreateIndex
CREATE INDEX "EpicTaskEdge_fromId_idx" ON "EpicTaskEdge"("fromId");
-- CreateIndex
CREATE INDEX "EpicTaskEdge_toId_idx" ON "EpicTaskEdge"("toId");
-- CreateIndex
CREATE UNIQUE INDEX "EpicTaskEdge_fromId_toId_key" ON "EpicTaskEdge"("fromId", "toId");
-- CreateIndex
CREATE INDEX "Task_epicTaskId_idx" ON "Task"("epicTaskId");
-- CreateIndex
CREATE INDEX "Task_state_idx" ON "Task"("state");
-- CreateIndex
CREATE INDEX "Task_assigneeId_idx" ON "Task"("assigneeId");
-- CreateIndex
CREATE UNIQUE INDEX "Task_name_epicTaskId_key" ON "Task"("name", "epicTaskId");
-- CreateIndex
CREATE INDEX "TaskVerdict_taskId_idx" ON "TaskVerdict"("taskId");
-- CreateIndex
CREATE INDEX "TaskVerdict_taskId_stage_idx" ON "TaskVerdict"("taskId", "stage");
-- CreateIndex
CREATE INDEX "TaskEvent_taskId_createdAt_idx" ON "TaskEvent"("taskId", "createdAt");
-- CreateIndex
CREATE INDEX "CodeRepo_projectId_idx" ON "CodeRepo"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "CodeRepo_name_projectId_key" ON "CodeRepo"("name", "projectId");
-- CreateIndex
CREATE INDEX "AgentProject_agentId_idx" ON "AgentProject"("agentId");
-- CreateIndex
CREATE INDEX "AgentProject_projectId_idx" ON "AgentProject"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "AgentProject_agentId_projectId_key" ON "AgentProject"("agentId", "projectId");
-- CreateIndex
CREATE INDEX "AgentSession_state_idx" ON "AgentSession"("state");
-- CreateIndex
CREATE INDEX "AgentSession_taskId_idx" ON "AgentSession"("taskId");
-- CreateIndex
CREATE INDEX "AgentSession_agentId_idx" ON "AgentSession"("agentId");
-- AddForeignKey
ALTER TABLE "Epic" ADD CONSTRAINT "Epic_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Epic" ADD CONSTRAINT "Epic_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EpicTask" ADD CONSTRAINT "EpicTask_epicId_fkey" FOREIGN KEY ("epicId") REFERENCES "Epic"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EpicTask" ADD CONSTRAINT "EpicTask_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EpicTaskEdge" ADD CONSTRAINT "EpicTaskEdge_fromId_fkey" FOREIGN KEY ("fromId") REFERENCES "EpicTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EpicTaskEdge" ADD CONSTRAINT "EpicTaskEdge_toId_fkey" FOREIGN KEY ("toId") REFERENCES "EpicTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_epicTaskId_fkey" FOREIGN KEY ("epicTaskId") REFERENCES "EpicTask"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "CodeRepo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskVerdict" ADD CONSTRAINT "TaskVerdict_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskVerdict" ADD CONSTRAINT "TaskVerdict_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskEvent" ADD CONSTRAINT "TaskEvent_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CodeRepo" ADD CONSTRAINT "CodeRepo_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CodeRepo" ADD CONSTRAINT "CodeRepo_credSecretId_fkey" FOREIGN KEY ("credSecretId") REFERENCES "Secret"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CodeRepo" ADD CONSTRAINT "CodeRepo_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentProject" ADD CONSTRAINT "AgentProject_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentProject" ADD CONSTRAINT "AgentProject_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentProject" ADD CONSTRAINT "AgentProject_llmId_fkey" FOREIGN KEY ("llmId") REFERENCES "Llm"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentSession" ADD CONSTRAINT "AgentSession_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -28,6 +28,12 @@ model User {
mcpTokens McpToken[]
ownedAgents Agent[]
chatThreads ChatThread[]
// ── Agentic teams ──
ownedEpics Epic[]
ownedEpicTasks EpicTask[]
ownedTasks Task[]
ownedRepos CodeRepo[]
ownedSessions AgentSession[]
@@index([email])
}
@@ -168,8 +174,10 @@ model Secret {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
backend SecretBackend @relation(fields: [backendId], references: [id])
llms Llm[]
backend SecretBackend @relation(fields: [backendId], references: [id])
llms Llm[]
// Agentic teams: git-provider credentials for CodeRepo.
codeRepos CodeRepo[]
@@index([name])
@@index([backendId])
@@ -256,6 +264,9 @@ model Llm {
apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
agents Agent[]
// Agentic teams: per-membership brain override.
agentProjects AgentProject[]
@@index([name])
@@index([tier])
@@index([apiKeySecretId])
@@ -329,15 +340,22 @@ model Project {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
servers ProjectServer[]
prompts Prompt[]
promptRequests PromptRequest[]
proposals ResourceProposal[]
skills Skill[]
mcpTokens McpToken[]
agents Agent[]
chatThreads ChatThread[]
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
servers ProjectServer[]
prompts Prompt[]
promptRequests PromptRequest[]
proposals ResourceProposal[]
skills Skill[]
mcpTokens McpToken[]
// Legacy single-project pin on Agent. Coexists with agentMemberships during
// the AgentProject transition release.
agents Agent[]
chatThreads ChatThread[]
// ── Agentic teams ──
epics Epic[]
codeRepos CodeRepo[]
agentMemberships AgentProject[]
sessions AgentSession[]
@@index([name])
@@index([ownerId])
@@ -692,6 +710,11 @@ model Agent {
skills Skill[]
personalities Personality[] @relation("AgentPersonalities")
defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)
// ── Agentic teams ──
projectMemberships AgentProject[]
assignedTasks Task[]
verdicts TaskVerdict[]
sessions AgentSession[]
@@index([name])
@@index([llmId])
@@ -888,3 +911,317 @@ model AuditLog {
@@index([resource])
@@index([createdAt])
}
// ═══════════════════════════════════════════════════════════════════════════
// Agentic Teams — vision layer, work items, repos, membership, sessions.
// See docs/agentic-teams.md and .taskmaster/docs/prd-agentic-teams.md.
//
// NOTE: deploy/entrypoint.sh provisions with `prisma db push`, NOT
// `prisma migrate deploy`, so hand-written SQL in prisma/migrations/ never runs
// in production. Every invariant below is therefore either expressible in this
// schema (unique / non-null / enum / FK) or enforced in the service layer with
// a test. Do not add CHECK constraints and assume they hold.
// ═══════════════════════════════════════════════════════════════════════════
// ── Epic (the vision — project-scoped, revision-tracked) ──
model Epic {
id String @id @default(cuid())
name String
description String @default("")
// The agreed statement of the vision. Body of the Epic.
content String @db.Text
// Checkable outcomes + intent/quality attributes defining 100% delivery:
// [{ id, text, kind: 'outcome' | 'quality', met: boolean }]
doneCriteria Json @default("[]")
// Accepted-tradeoff records: [{ what, why, who, deScopedBy, at }]. An Epic is
// honestly "done" only at 100% delivery OR with these explicitly recorded.
acceptedTradeoffs Json @default("[]")
projectId String
status EpicStatus @default(open)
// Semantic version of the current content, auto-bumped on save like Prompt.
semver String @default("0.1.0")
// Soft pointer to the latest ResourceRevision row. NULL before first revision.
currentRevisionId String?
ownerId String
// Optimistic-concurrency counter. NOT semver — that's `semver` above.
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
epicTasks EpicTask[]
@@unique([name, projectId])
@@index([projectId])
@@index([name])
}
enum EpicStatus {
open
delivered
delivered_with_tradeoffs
cancelled
}
// ── EpicTask (a roadmap node; the planning/navigation layer) ──
model EpicTask {
id String @id @default(cuid())
name String
description String @default("")
epicId String
// The decomposed subset of the Epic's doneCriteria this node owns, so the
// roadmap can show per-node completeness rather than one global percentage.
doneCriteria Json @default("[]")
status EpicTaskStatus @default(todo)
ownerId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
epic Epic @relation(fields: [epicId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
tasks Task[]
dependsOn EpicTaskEdge[] @relation("EpicTaskEdgeFrom")
blocks EpicTaskEdge[] @relation("EpicTaskEdgeTo")
@@unique([name, epicId])
@@index([epicId])
}
enum EpicTaskStatus {
todo
in_progress
done
cancelled
}
// ── EpicTaskEdge (roadmap chaining — the "spider web") ──
//
// An explicit edge table rather than a self-referencing array so the graph can
// be walked in both directions and edges can later carry a type. Cycles are
// rejected in the service layer (see the db-push note above).
model EpicTaskEdge {
id String @id @default(cuid())
fromId String // the dependent node
toId String // the prerequisite node
createdAt DateTime @default(now())
from EpicTask @relation("EpicTaskEdgeFrom", fields: [fromId], references: [id], onDelete: Cascade)
to EpicTask @relation("EpicTaskEdgeTo", fields: [toId], references: [id], onDelete: Cascade)
@@unique([fromId, toId])
@@index([fromId])
@@index([toId])
}
// ── Task (the unit of work, owned by an EpicTask) ──
model Task {
id String @id @default(cuid())
name String
title String
description String @default("") @db.Text
epicTaskId String
repoId String?
// The agent currently responsible. NULL while unassigned.
assigneeId String?
state TaskState @default(todo)
// Drives how much Tier-1 verification is engaged: trivial work may pass on a
// light self-check; risky work engages QA / Security / SRE.
risk TaskRisk @default(normal)
// Git linkage, populated when the agent pushes. The human accepts inside
// mcpctl and never has to go to git.
branch String?
headCommit String?
prUrl String?
prNumber Int?
// Free-form reviewer comment attached at the accept gate.
reviewNote String? @db.Text
ownerId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
epicTask EpicTask @relation(fields: [epicTaskId], references: [id], onDelete: Cascade)
repo CodeRepo? @relation(fields: [repoId], references: [id], onDelete: SetNull)
assignee Agent? @relation(fields: [assigneeId], references: [id], onDelete: SetNull)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
verdicts TaskVerdict[]
sessions AgentSession[]
events TaskEvent[]
@@unique([name, epicTaskId])
@@index([epicTaskId])
@@index([state])
@@index([assigneeId])
}
enum TaskState {
todo
in_progress
building // compile, run, smoke
self_testing // developer's own tests
qa_review
security_review
sre_review
done // leaves the developer's loop
awaiting_review // human accept gate
merged
reopened // post-merge failure
cancelled
}
enum TaskRisk {
trivial // docs, formatting
normal
high // auth, secrets, migrations, cross-contract
}
// ── TaskVerdict (a Tier-1 persona's judgement, pinned to a commit) ──
//
// Immutable provenance: "QA verified 3f2ab against current main" stays
// reproducible forever, which is what makes coverage claims auditable later.
model TaskVerdict {
id String @id @default(cuid())
taskId String
// qa_review | security_review | sre_review
stage TaskState
agentId String?
commit String
passed Boolean
summary String @db.Text
// [{ severity, message, path?, line? }]
findings Json @default("[]")
createdAt DateTime @default(now())
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
@@index([taskId])
@@index([taskId, stage])
}
// ── TaskEvent (append-only transition log) ──
model TaskEvent {
id String @id @default(cuid())
taskId String
fromState TaskState?
toState TaskState
// 'user' | 'agent' | 'system'
actorType String
actorId String?
note String? @db.Text
createdAt DateTime @default(now())
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@index([taskId, createdAt])
}
// ── CodeRepo (a git repository attached to a project) ──
//
// Named CodeRepo, not Repository, to avoid colliding with the data-access
// "repository" layer in src/mcpd/src/repositories/. CLI resource is `repos`,
// with repo/repository/repositories all aliased to it.
model CodeRepo {
id String @id @default(cuid())
name String
projectId String
// Clone URL. https for token auth, ssh for key auth.
url String
defaultBranch String @default("main")
// Relative path under the session workspace root. Defaults to `name`.
path String @default("")
// Git provider, for PR/merge API calls. 'gitea' is the only driver in v1.
provider String @default("gitea")
// Secret holding the provider credential, resolved through SecretService at
// session-provision time — never stored in plaintext on the row.
credSecretId String?
credSecretKey String?
ownerId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
cred Secret? @relation(fields: [credSecretId], references: [id])
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
tasks Task[]
@@unique([name, projectId])
@@index([projectId])
}
// ── AgentProject (agents are people who join many projects) ──
//
// Mirrors the ProjectServer junction shape. Agent.projectId is retained and
// kept in sync for one release so existing findByProjectId / prepareContext
// paths keep working; a follow-up removes it.
model AgentProject {
id String @id @default(cuid())
agentId String
projectId String
// Per-project persona role. TEXT, not an enum, so adding a role later is a
// non-migration change (same reasoning as ResourceProposal.resourceType).
// v1 values: 'developer' | 'qa' | 'security' | 'sre'.
role String @default("developer")
// Optional per-membership brain override. Present so the brain-ownership
// question (docs/agentic-teams.md §15 Q1) can be settled either way without
// a migration.
llmId String?
createdAt DateTime @default(now())
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
llm Llm? @relation(fields: [llmId], references: [id], onDelete: SetNull)
@@unique([agentId, projectId])
@@index([agentId])
@@index([projectId])
}
// ── AgentSession (the ephemeral pod that IS the agent's workspace) ──
model AgentSession {
id String @id @default(cuid())
agentId String
projectId String
taskId String?
// Orchestrator handle — pod name on k8s, container id on docker.
containerId String?
state SessionState @default(pending)
image String
// Reaped when now() > expiresAt, by a GC sweep in the same style as the
// inference-task sweep. Single-replica assumption: no leader election.
expiresAt DateTime
lastError String? @db.Text
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
endedAt DateTime?
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
@@index([state])
@@index([taskId])
@@index([agentId])
}
enum SessionState {
pending
provisioning
running
ended
failed
}

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