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:
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
428
src/db/tests/agentic-teams-schema.test.ts
Normal file
428
src/db/tests/agentic-teams-schema.test.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
|
||||
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
|
||||
|
||||
/**
|
||||
* Schema-level cover for the agentic-teams foundation: defaults, the uniqueness
|
||||
* rules that make names addressable, and the cascade/SetNull behaviour that
|
||||
* decides what survives a delete.
|
||||
*
|
||||
* Cascade rules are load-bearing here, not incidental — deleting a Project must
|
||||
* take its Epics and work items with it, but deleting the *repo* or the
|
||||
* *assignee* must leave the Task standing, because a task outlives the agent
|
||||
* that happened to be working it.
|
||||
*/
|
||||
describe('Agentic teams schema', () => {
|
||||
let prisma: PrismaClient;
|
||||
let seq = 0;
|
||||
|
||||
const uniq = (p: string): string => `${p}-${Date.now()}-${seq++}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = await setupTestDb();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAllTables(prisma);
|
||||
});
|
||||
|
||||
async function createUser() {
|
||||
return prisma.user.create({
|
||||
data: { email: `${uniq('u')}@example.com`, name: 'Test', passwordHash: '!locked', role: 'USER' },
|
||||
});
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const user = await createUser();
|
||||
const project = await prisma.project.create({ data: { name: uniq('proj'), ownerId: user.id } });
|
||||
return { project, user };
|
||||
}
|
||||
|
||||
async function createLlm() {
|
||||
return prisma.llm.create({ data: { name: uniq('llm'), type: 'openai', model: 'test' } });
|
||||
}
|
||||
|
||||
async function createAgent() {
|
||||
const user = await createUser();
|
||||
const llm = await createLlm();
|
||||
return prisma.agent.create({ data: { name: uniq('agent'), llmId: llm.id, ownerId: user.id } });
|
||||
}
|
||||
|
||||
async function createEpic(projectId: string, ownerId: string) {
|
||||
return prisma.epic.create({
|
||||
data: { name: uniq('epic'), content: 'the vision', projectId, ownerId },
|
||||
});
|
||||
}
|
||||
|
||||
async function createEpicTask(epicId: string, ownerId: string) {
|
||||
return prisma.epicTask.create({ data: { name: uniq('et'), epicId, ownerId } });
|
||||
}
|
||||
|
||||
async function createTask(epicTaskId: string, ownerId: string, extra: Record<string, unknown> = {}) {
|
||||
return prisma.task.create({
|
||||
data: { name: uniq('task'), title: 'Do the thing', epicTaskId, ownerId, ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
describe('Epic', () => {
|
||||
it('applies documented defaults', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
|
||||
expect(epic.status).toBe('open');
|
||||
expect(epic.semver).toBe('0.1.0');
|
||||
expect(epic.version).toBe(1);
|
||||
expect(epic.currentRevisionId).toBeNull();
|
||||
expect(epic.description).toBe('');
|
||||
expect(epic.doneCriteria).toEqual([]);
|
||||
expect(epic.acceptedTradeoffs).toEqual([]);
|
||||
});
|
||||
|
||||
it('scopes name uniqueness per project, not globally', async () => {
|
||||
const a = await createProject();
|
||||
const b = await createProject();
|
||||
const name = uniq('shared-epic');
|
||||
|
||||
await prisma.epic.create({ data: { name, content: 'x', projectId: a.project.id, ownerId: a.user.id } });
|
||||
// Same name in a different project is fine...
|
||||
await expect(
|
||||
prisma.epic.create({ data: { name, content: 'x', projectId: b.project.id, ownerId: b.user.id } }),
|
||||
).resolves.toBeDefined();
|
||||
// ...but a duplicate within one project is not.
|
||||
await expect(
|
||||
prisma.epic.create({ data: { name, content: 'y', projectId: a.project.id, ownerId: a.user.id } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('records accepted tradeoffs as structured JSON', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const tradeoff = { what: 'no dark mode', why: 'out of budget', who: 'michal', deScopedBy: 'michal' };
|
||||
|
||||
const updated = await prisma.epic.update({
|
||||
where: { id: epic.id },
|
||||
data: { acceptedTradeoffs: [tradeoff], status: 'delivered_with_tradeoffs' },
|
||||
});
|
||||
|
||||
expect(updated.acceptedTradeoffs).toEqual([tradeoff]);
|
||||
expect(updated.status).toBe('delivered_with_tradeoffs');
|
||||
});
|
||||
|
||||
it('cascades from Project', async () => {
|
||||
const { project, user } = await createProject();
|
||||
await createEpic(project.id, user.id);
|
||||
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
|
||||
expect(await prisma.epic.count({ where: { projectId: project.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpicTask and the roadmap graph', () => {
|
||||
it('chains nodes through an explicit edge table', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const foundation = await createEpicTask(epic.id, user.id);
|
||||
const dependent = await createEpicTask(epic.id, user.id);
|
||||
|
||||
await prisma.epicTaskEdge.create({ data: { fromId: dependent.id, toId: foundation.id } });
|
||||
|
||||
const loaded = await prisma.epicTask.findUnique({
|
||||
where: { id: dependent.id },
|
||||
include: { dependsOn: true, blocks: true },
|
||||
});
|
||||
expect(loaded?.dependsOn).toHaveLength(1);
|
||||
expect(loaded?.dependsOn[0]?.toId).toBe(foundation.id);
|
||||
|
||||
// ...and the graph is walkable from the other side too.
|
||||
const reverse = await prisma.epicTask.findUnique({
|
||||
where: { id: foundation.id },
|
||||
include: { blocks: true },
|
||||
});
|
||||
expect(reverse?.blocks).toHaveLength(1);
|
||||
expect(reverse?.blocks[0]?.fromId).toBe(dependent.id);
|
||||
});
|
||||
|
||||
it('rejects a duplicate edge between the same pair', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const a = await createEpicTask(epic.id, user.id);
|
||||
const b = await createEpicTask(epic.id, user.id);
|
||||
|
||||
await prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } });
|
||||
await expect(prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does NOT reject a cycle — that is the service layer\'s job', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const a = await createEpicTask(epic.id, user.id);
|
||||
const b = await createEpicTask(epic.id, user.id);
|
||||
|
||||
await prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } });
|
||||
// Documents the boundary: production runs `prisma db push`, so a CHECK or
|
||||
// trigger here would never exist in prod. Cycle rejection lives in the
|
||||
// EpicTask service and is tested there.
|
||||
await expect(prisma.epicTaskEdge.create({ data: { fromId: b.id, toId: a.id } })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('cascades edges when a node is deleted', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const a = await createEpicTask(epic.id, user.id);
|
||||
const b = await createEpicTask(epic.id, user.id);
|
||||
await prisma.epicTaskEdge.create({ data: { fromId: a.id, toId: b.id } });
|
||||
|
||||
await prisma.epicTask.delete({ where: { id: a.id } });
|
||||
|
||||
expect(await prisma.epicTaskEdge.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task', () => {
|
||||
it('applies documented defaults', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
|
||||
expect(task.state).toBe('todo');
|
||||
expect(task.risk).toBe('normal');
|
||||
expect(task.assigneeId).toBeNull();
|
||||
expect(task.repoId).toBeNull();
|
||||
expect(task.branch).toBeNull();
|
||||
expect(task.prUrl).toBeNull();
|
||||
expect(task.prNumber).toBeNull();
|
||||
expect(task.reviewNote).toBeNull();
|
||||
expect(task.version).toBe(1);
|
||||
});
|
||||
|
||||
it('accepts every lifecycle state', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
|
||||
const states = [
|
||||
'in_progress', 'building', 'self_testing', 'qa_review', 'security_review',
|
||||
'sre_review', 'done', 'awaiting_review', 'merged', 'reopened', 'cancelled',
|
||||
] as const;
|
||||
|
||||
for (const state of states) {
|
||||
const updated = await prisma.task.update({ where: { id: task.id }, data: { state } });
|
||||
expect(updated.state).toBe(state);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the task when its assignee agent is deleted', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const agent = await createAgent();
|
||||
const task = await createTask(epicTask.id, user.id, { assigneeId: agent.id });
|
||||
|
||||
await prisma.agent.delete({ where: { id: agent.id } });
|
||||
|
||||
// A task outlives whoever happened to be working it.
|
||||
const after = await prisma.task.findUnique({ where: { id: task.id } });
|
||||
expect(after).not.toBeNull();
|
||||
expect(after?.assigneeId).toBeNull();
|
||||
});
|
||||
|
||||
it('cascades verdicts and events, and keeps them commit-pinned', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
|
||||
await prisma.taskVerdict.create({
|
||||
data: {
|
||||
taskId: task.id, stage: 'qa_review', commit: '3f2ab19', passed: false,
|
||||
summary: 'missing coverage on the error path',
|
||||
findings: [{ severity: 'major', message: 'no test for 500', path: 'src/a.ts' }],
|
||||
},
|
||||
});
|
||||
await prisma.taskEvent.create({
|
||||
data: { taskId: task.id, fromState: 'qa_review', toState: 'in_progress', actorType: 'agent' },
|
||||
});
|
||||
|
||||
const verdict = await prisma.taskVerdict.findFirst({ where: { taskId: task.id } });
|
||||
expect(verdict?.commit).toBe('3f2ab19');
|
||||
expect(verdict?.passed).toBe(false);
|
||||
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
expect(await prisma.taskVerdict.count()).toBe(0);
|
||||
expect(await prisma.taskEvent.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('allows a TaskEvent with no fromState — the first transition', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
|
||||
const event = await prisma.taskEvent.create({
|
||||
data: { taskId: task.id, toState: 'todo', actorType: 'system' },
|
||||
});
|
||||
expect(event.fromState).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodeRepo', () => {
|
||||
it('applies documented defaults', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const repo = await prisma.codeRepo.create({
|
||||
data: { name: uniq('repo'), projectId: project.id, url: 'https://git.example/x.git', ownerId: user.id },
|
||||
});
|
||||
|
||||
expect(repo.defaultBranch).toBe('main');
|
||||
expect(repo.provider).toBe('gitea');
|
||||
expect(repo.path).toBe('');
|
||||
expect(repo.credSecretId).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the task when its repo is deleted', async () => {
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const repo = await prisma.codeRepo.create({
|
||||
data: { name: uniq('repo'), projectId: project.id, url: 'https://git.example/x.git', ownerId: user.id },
|
||||
});
|
||||
const task = await createTask(epicTask.id, user.id, { repoId: repo.id });
|
||||
|
||||
await prisma.codeRepo.delete({ where: { id: repo.id } });
|
||||
|
||||
const after = await prisma.task.findUnique({ where: { id: task.id } });
|
||||
expect(after?.repoId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentProject membership', () => {
|
||||
it('lets one agent join many projects with different roles', async () => {
|
||||
const agent = await createAgent();
|
||||
const a = await createProject();
|
||||
const b = await createProject();
|
||||
|
||||
await prisma.agentProject.create({ data: { agentId: agent.id, projectId: a.project.id, role: 'developer' } });
|
||||
await prisma.agentProject.create({ data: { agentId: agent.id, projectId: b.project.id, role: 'security' } });
|
||||
|
||||
const memberships = await prisma.agentProject.findMany({ where: { agentId: agent.id } });
|
||||
expect(memberships).toHaveLength(2);
|
||||
expect(memberships.map((m) => m.role).sort()).toEqual(['developer', 'security']);
|
||||
});
|
||||
|
||||
it('defaults the role to developer and leaves the brain override unset', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project } = await createProject();
|
||||
|
||||
const membership = await prisma.agentProject.create({
|
||||
data: { agentId: agent.id, projectId: project.id },
|
||||
});
|
||||
expect(membership.role).toBe('developer');
|
||||
expect(membership.llmId).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects joining the same project twice', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project } = await createProject();
|
||||
|
||||
await prisma.agentProject.create({ data: { agentId: agent.id, projectId: project.id } });
|
||||
await expect(
|
||||
prisma.agentProject.create({ data: { agentId: agent.id, projectId: project.id } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('drops the membership but keeps the agent when a project goes away', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project } = await createProject();
|
||||
await prisma.agentProject.create({ data: { agentId: agent.id, projectId: project.id } });
|
||||
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
|
||||
expect(await prisma.agentProject.count({ where: { agentId: agent.id } })).toBe(0);
|
||||
expect(await prisma.agent.findUnique({ where: { id: agent.id } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears only the brain override when its Llm is deleted', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project } = await createProject();
|
||||
const llm = await createLlm();
|
||||
const membership = await prisma.agentProject.create({
|
||||
data: { agentId: agent.id, projectId: project.id, llmId: llm.id },
|
||||
});
|
||||
|
||||
await prisma.llm.delete({ where: { id: llm.id } });
|
||||
|
||||
const after = await prisma.agentProject.findUnique({ where: { id: membership.id } });
|
||||
expect(after).not.toBeNull();
|
||||
expect(after?.llmId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentSession', () => {
|
||||
it('applies documented defaults', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project, user } = await createProject();
|
||||
|
||||
const session = await prisma.agentSession.create({
|
||||
data: {
|
||||
agentId: agent.id, projectId: project.id, ownerId: user.id,
|
||||
image: 'mcpctl-agent-runner:latest', expiresAt: new Date(Date.now() + 3600_000),
|
||||
},
|
||||
});
|
||||
|
||||
expect(session.state).toBe('pending');
|
||||
expect(session.containerId).toBeNull();
|
||||
expect(session.taskId).toBeNull();
|
||||
expect(session.endedAt).toBeNull();
|
||||
expect(session.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it('survives its task being deleted — the pod record outlives the work item', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project, user } = await createProject();
|
||||
const epic = await createEpic(project.id, user.id);
|
||||
const epicTask = await createEpicTask(epic.id, user.id);
|
||||
const task = await createTask(epicTask.id, user.id);
|
||||
const session = await prisma.agentSession.create({
|
||||
data: {
|
||||
agentId: agent.id, projectId: project.id, taskId: task.id, ownerId: user.id,
|
||||
image: 'x:latest', expiresAt: new Date(Date.now() + 3600_000),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
|
||||
const after = await prisma.agentSession.findUnique({ where: { id: session.id } });
|
||||
expect(after).not.toBeNull();
|
||||
expect(after?.taskId).toBeNull();
|
||||
});
|
||||
|
||||
it('finds expired sessions for the GC sweep', async () => {
|
||||
const agent = await createAgent();
|
||||
const { project, user } = await createProject();
|
||||
await prisma.agentSession.create({
|
||||
data: {
|
||||
agentId: agent.id, projectId: project.id, ownerId: user.id, image: 'x:latest',
|
||||
state: 'running', expiresAt: new Date(Date.now() - 1000),
|
||||
},
|
||||
});
|
||||
await prisma.agentSession.create({
|
||||
data: {
|
||||
agentId: agent.id, projectId: project.id, ownerId: user.id, image: 'x:latest',
|
||||
state: 'running', expiresAt: new Date(Date.now() + 3600_000),
|
||||
},
|
||||
});
|
||||
|
||||
const expired = await prisma.agentSession.findMany({
|
||||
where: { state: { in: ['pending', 'provisioning', 'running'] }, expiresAt: { lt: new Date() } },
|
||||
});
|
||||
expect(expired).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,18 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
|
||||
await client.resourceProposal.deleteMany();
|
||||
// Revisions have no FK (soft FK); always orphans without explicit clear.
|
||||
await client.resourceRevision.deleteMany();
|
||||
// Agentic teams — leaves first, then up the chain. Most of these cascade from
|
||||
// Project/Agent/User, but deleting them explicitly keeps the order obvious and
|
||||
// stops a future nullable FK from silently leaking rows between tests.
|
||||
await client.agentSession.deleteMany();
|
||||
await client.taskEvent.deleteMany();
|
||||
await client.taskVerdict.deleteMany();
|
||||
await client.task.deleteMany();
|
||||
await client.epicTaskEdge.deleteMany();
|
||||
await client.epicTask.deleteMany();
|
||||
await client.epic.deleteMany();
|
||||
await client.codeRepo.deleteMany();
|
||||
await client.agentProject.deleteMany();
|
||||
await client.agent.deleteMany();
|
||||
await client.llm.deleteMany();
|
||||
await client.mcpInstance.deleteMany();
|
||||
|
||||
Reference in New Issue
Block a user