diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index e597da7..98e1ad2 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -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" + ] } } -} +} \ No newline at end of file diff --git a/src/db/prisma/migrations/20260808103000_add_agentic_teams/migration.sql b/src/db/prisma/migrations/20260808103000_add_agentic_teams/migration.sql new file mode 100644 index 0000000..99609eb --- /dev/null +++ b/src/db/prisma/migrations/20260808103000_add_agentic_teams/migration.sql @@ -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; + diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index b8de194..bbe3c65 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -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 +} diff --git a/src/db/tests/agentic-teams-schema.test.ts b/src/db/tests/agentic-teams-schema.test.ts new file mode 100644 index 0000000..af16713 --- /dev/null +++ b/src/db/tests/agentic-teams-schema.test.ts @@ -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 = {}) { + 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); + }); + }); +}); diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index 8926ba1..7d80678 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -42,6 +42,18 @@ export async function clearAllTables(client: PrismaClient): Promise { 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();