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.",
|
"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",
|
"priority": "high",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T09:22:12.130Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "116",
|
"id": "116",
|
||||||
@@ -2572,9 +2572,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"115"
|
"115"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T09:22:12.140Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "117",
|
"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.",
|
"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",
|
"priority": "high",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T09:29:43.358Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "118",
|
"id": "118",
|
||||||
@@ -2598,9 +2598,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"117"
|
"117"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T09:29:43.369Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "119",
|
"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.",
|
"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",
|
"priority": "high",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T09:29:43.377Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "120",
|
"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.",
|
"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",
|
"priority": "high",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T09:29:43.384Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "121",
|
"id": "121",
|
||||||
@@ -2637,9 +2637,9 @@
|
|||||||
"118",
|
"118",
|
||||||
"120"
|
"120"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T09:29:43.392Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "122",
|
"id": "122",
|
||||||
@@ -2655,9 +2655,9 @@
|
|||||||
"120",
|
"120",
|
||||||
"121"
|
"121"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-08T09:29:43.398Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "123",
|
"id": "123",
|
||||||
@@ -3250,9 +3250,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"created": "2026-08-08T08:54:31.861Z",
|
"version": "1.0.0",
|
||||||
"updated": "2026-08-08T08:54:31.861Z",
|
"lastModified": "2026-08-08T09:29:43.398Z",
|
||||||
"description": "Tasks for master context"
|
"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[]
|
mcpTokens McpToken[]
|
||||||
ownedAgents Agent[]
|
ownedAgents Agent[]
|
||||||
chatThreads ChatThread[]
|
chatThreads ChatThread[]
|
||||||
|
// ── Agentic teams ──
|
||||||
|
ownedEpics Epic[]
|
||||||
|
ownedEpicTasks EpicTask[]
|
||||||
|
ownedTasks Task[]
|
||||||
|
ownedRepos CodeRepo[]
|
||||||
|
ownedSessions AgentSession[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@@ -168,8 +174,10 @@ model Secret {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
backend SecretBackend @relation(fields: [backendId], references: [id])
|
backend SecretBackend @relation(fields: [backendId], references: [id])
|
||||||
llms Llm[]
|
llms Llm[]
|
||||||
|
// Agentic teams: git-provider credentials for CodeRepo.
|
||||||
|
codeRepos CodeRepo[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([backendId])
|
@@index([backendId])
|
||||||
@@ -256,6 +264,9 @@ model Llm {
|
|||||||
apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
|
apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
|
||||||
agents Agent[]
|
agents Agent[]
|
||||||
|
|
||||||
|
// Agentic teams: per-membership brain override.
|
||||||
|
agentProjects AgentProject[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([tier])
|
@@index([tier])
|
||||||
@@index([apiKeySecretId])
|
@@index([apiKeySecretId])
|
||||||
@@ -329,15 +340,22 @@ model Project {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
servers ProjectServer[]
|
servers ProjectServer[]
|
||||||
prompts Prompt[]
|
prompts Prompt[]
|
||||||
promptRequests PromptRequest[]
|
promptRequests PromptRequest[]
|
||||||
proposals ResourceProposal[]
|
proposals ResourceProposal[]
|
||||||
skills Skill[]
|
skills Skill[]
|
||||||
mcpTokens McpToken[]
|
mcpTokens McpToken[]
|
||||||
agents Agent[]
|
// Legacy single-project pin on Agent. Coexists with agentMemberships during
|
||||||
chatThreads ChatThread[]
|
// the AgentProject transition release.
|
||||||
|
agents Agent[]
|
||||||
|
chatThreads ChatThread[]
|
||||||
|
// ── Agentic teams ──
|
||||||
|
epics Epic[]
|
||||||
|
codeRepos CodeRepo[]
|
||||||
|
agentMemberships AgentProject[]
|
||||||
|
sessions AgentSession[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@ -692,6 +710,11 @@ model Agent {
|
|||||||
skills Skill[]
|
skills Skill[]
|
||||||
personalities Personality[] @relation("AgentPersonalities")
|
personalities Personality[] @relation("AgentPersonalities")
|
||||||
defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)
|
defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)
|
||||||
|
// ── Agentic teams ──
|
||||||
|
projectMemberships AgentProject[]
|
||||||
|
assignedTasks Task[]
|
||||||
|
verdicts TaskVerdict[]
|
||||||
|
sessions AgentSession[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([llmId])
|
@@index([llmId])
|
||||||
@@ -888,3 +911,317 @@ model AuditLog {
|
|||||||
@@index([resource])
|
@@index([resource])
|
||||||
@@index([createdAt])
|
@@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();
|
await client.resourceProposal.deleteMany();
|
||||||
// Revisions have no FK (soft FK); always orphans without explicit clear.
|
// Revisions have no FK (soft FK); always orphans without explicit clear.
|
||||||
await client.resourceRevision.deleteMany();
|
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.agent.deleteMany();
|
||||||
await client.llm.deleteMany();
|
await client.llm.deleteMany();
|
||||||
await client.mcpInstance.deleteMany();
|
await client.mcpInstance.deleteMany();
|
||||||
|
|||||||
Reference in New Issue
Block a user