Files
mcpctl/src/db/prisma/schema.prisma
Michal a5cab5a096
Some checks failed
CI/CD / lint (pull_request) Successful in 1m2s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / smoke (pull_request) Failing after 1m49s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped
feat(chat): project-scoped chat — mcpctl chat --project <name>
Chat directly with a Project (no Agent needed): its Prompts become the system
context, its MCP-server tools are callable, its llmProvider/llmModel drive the
LLM, and (opt-in) the model can read secret values. History is saved inside the
project, attributed per user, resumable, and deletable (RBAC-permitting) — "use
it like Claude, scoped to the project".

Backend (reuses the agent-chat orchestrator):
- ChatThread is now agent-XOR-project (schema + migration + CHECK constraint);
  new listThreadsByProject / deleteThread on the repo.
- ChatService: prepareProjectContext (project prompt + Prompts by priority,
  llm from llmProvider with llmModel override, project tools), shared
  runChatLoop/runChatStreamLoop, project thread CRUD with owner enforcement
  (404-not-403 on foreign threads), admin-override delete.
- Gated get_secret virtual tool: offered only with --allow-secrets AND the
  caller's view:secrets; resolves via SecretService, never routes to a server.
- routes/project-chat.ts (chat SSE+non-stream, threads create/list/delete);
  RBAC run:projects:<name>.

CLI:
- `mcpctl chat --project <name>` (+ --allow-secrets), one-shot/REPL/resume.
- REPL /threads, /resume <id>, /delete <id>; project-aware header + /tools.
- `mcpctl get threads --project <name>`, `mcpctl delete thread <id> --project`.
- completions regenerated (--project completes project names).

Tests: 8 new project-chat unit tests; full mcpd (945) + CLI (508) green;
schema validated against Postgres. Docs: docs/chat.md "Project chat" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:34:21 +01:00

890 lines
32 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ── Users ──
model User {
id String @id @default(cuid())
email String @unique
name String?
passwordHash String
role Role @default(USER)
provider String?
externalId String?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
auditLogs AuditLog[]
ownedProjects Project[]
groupMemberships GroupMember[]
mcpTokens McpToken[]
ownedAgents Agent[]
chatThreads ChatThread[]
@@index([email])
}
enum Role {
USER
ADMIN
}
// ── Sessions ──
model Session {
id String @id @default(cuid())
token String @unique
userId String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
@@index([expiresAt])
}
// ── MCP Servers ──
model McpServer {
id String @id @default(cuid())
name String @unique
description String @default("")
packageName String?
runtime String?
dockerImage String?
transport Transport @default(STDIO)
repositoryUrl String?
externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
healthCheck Json?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
templateName String?
templateVersion String?
instances McpInstance[]
projects ProjectServer[]
@@index([name])
}
enum Transport {
STDIO
SSE
STREAMABLE_HTTP
}
// ── MCP Templates ──
model McpTemplate {
id String @id @default(cuid())
name String @unique
version String @default("1.0.0")
description String @default("")
packageName String?
runtime String?
dockerImage String?
transport Transport @default(STDIO)
repositoryUrl String?
externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
healthCheck Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}
// ── Secret Backends ──
//
// Pluggable storage for Secret.data. Default is `plaintext` (data stored in
// Secret.data JSON). Other drivers (e.g. `openbao`) store only a reference in
// Secret.externalRef and fetch actual values from the external system at read
// time. A `plaintext` row is seeded on first startup so the system always has
// a viable backend; additional backends are user-managed via
// `mcpctl create secretbackend`.
model SecretBackend {
id String @id @default(cuid())
name String @unique
type String // plaintext | openbao | (future: vault, aws-sm, ...)
config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef
// Runtime metadata for auto-rotating backend credentials (openbao token
// auth). Fields: generatedAt, nextRenewalAt, validUntil, lastRotationAt,
// lastRotationError, rotatable (true only for wizard-provisioned tokens).
// Empty object for backends that don't use rotation (plaintext, kubernetes
// auth, or static tokens). Managed entirely by the rotator service.
tokenMeta Json @default("{}")
isDefault Boolean @default(false) // exactly one row has isDefault=true
description String @default("")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
secrets Secret[]
@@index([name])
@@index([isDefault])
}
// ── Secrets ──
model Secret {
id String @id @default(cuid())
name String @unique
// FK to SecretBackend. Default empty string lets `prisma db push` add the
// column to pre-existing rows without a data-loss reset; `bootstrapSecretBackends`
// then points any empty-string values at the seeded `default` plaintext backend
// on next mcpd startup. New rows written by SecretService always carry a
// valid FK immediately.
backendId String @default("")
data Json @default("{}") // populated by plaintext backend only
externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3")
// Sorted list of the secret's data keys WITHOUT their values. Populated on
// every create/update/migrate so list views and describe-without-reveal can
// show "this secret has GRAFANA_URL + GRAFANA_TOKEN" without fetching the
// backing data. For pre-existing rows the field is empty until the next
// write or a lazy resolve in getById fills it in.
keyNames Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
backend SecretBackend @relation(fields: [backendId], references: [id])
llms Llm[]
@@index([name])
@@index([backendId])
}
// ── LLMs ──
//
// Server-managed LLM providers. Clients (agent, HTTP-mode mcplocal) send
// OpenAI-format requests to `mcpd /api/v1/llms/:name/infer` — mcpd attaches the
// provider API key server-side so credentials never leave the cluster.
// Credentials are stored by reference: `apiKeySecret` points at a Secret, and
// `apiKeySecretKey` names the key within that secret's data.
//
// `kind=virtual` rows are *registered by an mcplocal client* (rather than a
// human via `mcpctl create llm`). Their inference is relayed back through
// the SSE control channel to the publishing mcplocal session. The lifecycle
// fields (lastHeartbeatAt, status, inactiveSince) belong to virtual rows;
// public rows ignore them.
enum LlmKind {
public // upstream-URL row, mcpd calls directly
virtual // mcplocal-registered, inference relayed via SSE control channel
}
enum LlmStatus {
active // healthy, accepting requests
inactive // publisher went away; row pending 4-h GC
hibernating // publisher present but backend asleep — wakes on demand (v2)
}
model Llm {
id String @id @default(cuid())
name String @unique
type String // anthropic | openai | deepseek | vllm | ollama | gemini-cli
model String // e.g. claude-3-5-sonnet-20241022
url String @default("") // endpoint (empty for provider default)
tier String @default("fast") // fast | heavy
description String @default("")
apiKeySecretId String? // FK to Secret
apiKeySecretKey String? // key inside the Secret's data
extraConfig Json @default("{}") // per-type extras
// ── LB pool by name (v4) ──
// When set, this Llm is part of a load-balanced pool. Multiple Llms
// sharing the same `poolName` stack into a pool that the chat dispatcher
// expands at request time and picks an active member from. When NULL,
// the effective pool key is the row's own `name` (i.e. "pool of 1",
// identical to pre-v4 behavior). Agents pin to a single Llm by id; the
// dispatcher transparently widens to the pool when this field is set.
poolName String?
// ── Virtual-provider lifecycle (NULL/default for kind=public) ──
kind LlmKind @default(public)
providerSessionId String? // mcplocal session that owns this row when virtual
lastHeartbeatAt DateTime? // bumped on every publisher heartbeat
status LlmStatus @default(active)
inactiveSince DateTime? // when status flipped from active; used for 4-h GC
// ── Per-user RBAC scoping (v7) ──
// `ownerId` records who created/published the row. NULL on legacy rows
// (those created before the v7 migration) — those continue to behave
// as `visibility=public` for back-compat. New rows always carry an
// ownerId set by the service layer (`User.id` of the authenticated
// caller, or the publishing mcplocal user for virtuals).
//
// `visibility` controls who can see / use the row:
// - 'public' : anyone with the resource grant (`view:llms`,
// `run:llms:<name>`, etc.) sees it. Legacy default
// mirrors today's behavior — explicit
// `mcpctl create llm` calls keep this default.
// - 'private' : only the owner sees it by default; other users need
// an explicit name-scoped RBAC binding
// (`view:llms:<name>`, `run:llms:<name>`). The list
// endpoint hides foreign-private rows from
// unauthorized callers; get/describe returns 404 to
// prevent name enumeration.
//
// mcplocal-published virtual Llms default to 'private' on register —
// a workstation-published model isn't typically meant for the whole
// org until the publisher explicitly shares it. See docs/virtual-llms.md.
ownerId String?
visibility String @default("public")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
agents Agent[]
@@index([name])
@@index([tier])
@@index([apiKeySecretId])
@@index([kind, status])
@@index([providerSessionId])
@@index([poolName])
// List filter on the hot path: "rows visible to caller X" decomposes
// into `visibility='public' OR ownerId=X` + an RBAC join. Composite
// index keeps the predicate fast even on a large table.
@@index([visibility, ownerId])
}
// ── Groups ──
model Group {
id String @id @default(cuid())
name String @unique
description String @default("")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members GroupMember[]
@@index([name])
}
model GroupMember {
id String @id @default(cuid())
groupId String
userId String
createdAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([groupId, userId])
@@index([groupId])
@@index([userId])
}
// ── RBAC Definitions ──
model RbacDefinition {
id String @id @default(cuid())
name String @unique
subjects Json @default("[]")
roleBindings Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}
// ── Projects ──
model Project {
id String @id @default(cuid())
name String @unique
description String @default("")
prompt String @default("")
proxyModel String @default("")
gated Boolean @default(true)
llmProvider String?
llmModel String?
serverOverrides Json?
ownerId String
version Int @default(1)
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[]
@@index([name])
@@index([ownerId])
}
model ProjectServer {
id String @id @default(cuid())
projectId String
serverId String
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([projectId, serverId])
}
// ── MCP Tokens (bearer credentials for HTTP-mode mcplocal) ──
//
// Raw value format: `mcpctl_pat_<32 base62 chars>`. The raw value is shown
// exactly once at create time; only the SHA-256 hash is persisted. Tokens are
// scoped to exactly one project — they're only valid at
// `/projects/<that-project>/mcp`. Creator's RBAC is the ceiling; the service
// rejects bindings that exceed what the creator themselves can do.
model McpToken {
id String @id @default(cuid())
name String
projectId String
tokenHash String @unique
tokenPrefix String
ownerId String
description String @default("")
createdAt DateTime @default(now())
expiresAt DateTime?
lastUsedAt DateTime?
revokedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
@@unique([name, projectId])
@@index([tokenHash])
@@index([projectId])
@@index([ownerId])
}
// ── MCP Instances (running containers) ──
model McpInstance {
id String @id @default(cuid())
serverId String
containerId String?
status InstanceStatus @default(STOPPED)
port Int?
metadata Json @default("{}")
healthStatus String?
lastHealthCheck DateTime?
events Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@index([serverId])
@@index([status])
}
enum InstanceStatus {
STARTING
RUNNING
STOPPING
STOPPED
ERROR
}
// ── Prompts (approved content resources) ──
model Prompt {
id String @id @default(cuid())
name String
content String @db.Text
projectId String?
agentId String?
priority Int @default(5)
summary String? @db.Text
chapters Json?
linkTarget String?
// Semantic version of the current content. Auto-bumped patch on every save
// by PromptService.update; author can pass --bump major|minor|patch or
// --semver X.Y.Z to override. NOT the same as `version` below — that one
// is the optimistic-concurrency counter and stays Int.
semver String @default("0.1.0")
// Soft pointer to the latest ResourceRevision row for this prompt. NULL
// before the first revision is recorded. Set in the same transaction as
// create/update by PromptService.
currentRevisionId String?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)
personalities PersonalityPrompt[]
@@unique([name, projectId])
@@unique([name, agentId])
@@index([projectId])
@@index([agentId])
}
// ── Skills (Claude Code skill bundles, synced to ~/.claude/skills/<name>/) ──
//
// Skills are the on-disk counterpart to Prompts. mcpd is the source of truth;
// mcpctl skills sync materialises them onto disk under
// ~/.claude/skills/<name>/SKILL.md (+ optional aux files) where Claude Code
// reads them natively. Same scoping rules as Prompt (project XOR agent XOR
// neither = global). Multi-file bundles live in `files` (path → content).
// Typed skill metadata (hooks, mcpServers, postInstall, …) is validated in
// the app layer and stored opaquely here in `metadata`.
model Skill {
id String @id @default(cuid())
name String
description String @default("")
// Body of the SKILL.md file delivered to Claude Code.
content String @db.Text
// Auxiliary files in the skill bundle. Map of relative path → file content
// (UTF-8 text only in v1; binaries deferred). Materialised onto disk at sync
// time alongside SKILL.md.
files Json @default("{}")
// Typed-but-stored-as-Json: { hooks?, mcpServers?, postInstall?,
// preUninstall?, postInstallTimeoutSec? }. Validated at the route layer
// (see src/mcpd/src/validation/skill.schema.ts in PR-3).
metadata Json @default("{}")
projectId String?
agentId String?
priority Int @default(5)
summary String? @db.Text
chapters Json?
// Semantic version of the current content. Auto-bumped patch on every save
// by SkillService.update; author can override via --bump or --semver.
semver String @default("0.1.0")
// Soft pointer to the latest ResourceRevision row for this skill. NULL
// before the first revision is recorded.
currentRevisionId 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)
agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)
@@unique([name, projectId])
@@unique([name, agentId])
@@index([projectId])
@@index([agentId])
@@index([name])
}
// ── Prompt Requests (pending proposals from LLM sessions) ──
model PromptRequest {
id String @id @default(cuid())
name String
content String @db.Text
projectId String?
priority Int @default(5)
createdBySession String?
createdByUserId String?
createdAt DateTime @default(now())
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([name, projectId])
@@index([projectId])
@@index([createdBySession])
}
// ── ResourceRevision (append-only audit + diff log) ──
//
// Both Prompt and Skill rows produce revisions on every change. Hot reads
// (gate plugin, mcpctl skills sync, prompt index for LLM selection) stay on
// the resource row's inline content; revisions are only consulted by
// history/diff/restore endpoints. Approving a ResourceProposal atomically
// inserts the resource + a revision in the same transaction.
//
// `resourceId` is a soft FK with NO referential constraint — revisions
// outlive the resources they describe so audit history isn't lost when
// a resource is deleted. Validated app-layer.
model ResourceRevision {
id String @id @default(cuid())
// Discriminator: 'prompt' | 'skill'. TEXT, not enum, to make adding a
// third resource type later a non-migration change.
resourceType String
resourceId String
semver String @default("0.1.0")
// sha256 of the canonicalised body — stable diff key. Two revisions with
// the same hash are byte-identical (skills sync uses this to skip work
// even when semver hasn't bumped).
contentHash String
// Snapshot of the resource at this revision: { content, metadata?, ... }
body Json
authorUserId String?
authorSessionId String?
note String @default("")
createdAt DateTime @default(now())
// History viewer: latest-first within a resource.
@@index([resourceType, resourceId, createdAt(sort: Desc)])
// Direct lookup by semver.
@@index([resourceType, resourceId, semver])
// Sync diff cross-resource lookup ("do I already have a revision with
// this contentHash anywhere?") — useful for detecting renames + dedup.
@@index([contentHash])
@@index([authorUserId])
}
// ── ResourceProposal (generic propose/approve/reject queue) ──
//
// A pending change to a Prompt or Skill, submitted by an LLM session via
// the propose_prompt / propose_skill MCP tools (see mcplocal gate plugin)
// or by a human via the web UI / CLI. Reviewers drain the queue via
// `mcpctl review next`. Approving creates the underlying resource (if new)
// and writes a ResourceRevision; the proposal status flips to 'approved'
// and `approvedRevisionId` points at the resulting revision row.
//
// Replaces the prompt-only PromptRequest table; the cutover happens in PR-2
// (rename + backfill + service rewire).
model ResourceProposal {
id String @id @default(cuid())
resourceType String // 'prompt' | 'skill'
name String
// Proposed body — { content, metadata? } shaped per resourceType.
body Json
projectId String?
agentId String?
createdBySession String?
createdByUserId String?
status String @default("pending") // pending | approved | rejected
reviewerNote String @default("")
// Set when status='approved': the ResourceRevision the approval created.
approvedRevisionId String?
// Optimistic-concurrency counter for concurrent reviewer actions.
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)
// Mirrors Prompt's two-unique pattern: a (type, name) pair can have one
// proposal per project XOR one per agent. NULL semantics inherit Postgres
// defaults (NULL distinct from NULL); the app layer reuses the `?? ''`
// workaround for direct compound-key lookups, same as Prompt.
@@unique([resourceType, name, projectId])
@@unique([resourceType, name, agentId])
@@index([resourceType, status])
@@index([projectId])
@@index([createdBySession])
}
// ── Audit Events (pipeline/gate/tool trace from mcplocal) ──
model AuditEvent {
id String @id @default(cuid())
timestamp DateTime
sessionId String
projectName String
eventKind String
source String
verified Boolean @default(false)
serverName String?
correlationId String?
parentEventId String?
userName String?
tokenName String?
tokenSha String?
payload Json
createdAt DateTime @default(now())
@@index([sessionId])
@@index([projectName])
@@index([correlationId])
@@index([timestamp])
@@index([eventKind])
@@index([userName])
@@index([tokenSha])
}
// ── Backup Pending Queue ──
model BackupPending {
id String @id @default(cuid())
resourceKind String
resourceName String
action String // 'create' | 'update' | 'delete'
userName String
yamlContent String? @db.Text
createdAt DateTime @default(now())
@@index([createdAt])
}
// ── Agents (LLM personas pinned to a specific Llm) ──
//
// Agents combine a system prompt, a pinned LLM, and (optionally) a project to
// inherit Prompts from. Each Agent is also exposed by mcplocal as a virtual
// MCP server (`agent-<name>/chat`), so other clients can consult it as a tool.
// Per-call LiteLLM-style overrides stack on top of `defaultParams`.
model Agent {
id String @id @default(cuid())
name String @unique
description String @default("") // shown in MCP tools/list
systemPrompt String @default("") @db.Text // agent persona
llmId String
projectId String?
defaultPersonalityId String? // applied at chat time when no --personality flag
proxyModelName String? // optional informational override
defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ...
extras Json @default("{}") // future LoRA / tool-allowlist
// ── Virtual-agent lifecycle (NULL/default for kind=public, mirrors Llm) ──
kind LlmKind @default(public)
providerSessionId String? // mcplocal session that owns this row when virtual
lastHeartbeatAt DateTime?
status LlmStatus @default(active)
inactiveSince DateTime?
ownerId String
// v7: per-user RBAC scoping. Mirrors `Llm.visibility` semantics —
// 'public' (default, today's behavior) lets anyone with the resource
// grant see the agent; 'private' restricts to owner + explicit
// name-scoped RBAC bindings. mcplocal-published virtual agents
// default to 'private' on register so a workstation-published persona
// isn't broadcast to the whole org until shared explicitly. Existing
// rows backfill to 'public' so pre-v7 setups keep working unchanged.
visibility String @default("public")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
threads ChatThread[]
prompts Prompt[]
proposals ResourceProposal[]
skills Skill[]
personalities Personality[] @relation("AgentPersonalities")
defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)
@@index([name])
@@index([llmId])
@@index([projectId])
@@index([ownerId])
@@index([defaultPersonalityId])
@@index([kind, status])
@@index([providerSessionId])
@@index([visibility, ownerId])
}
// ── Personalities (named overlay bundles of prompts on top of an Agent) ──
//
// VLAN-on-ethernet semantics: an Agent works without a Personality (today's
// flow). Selecting a Personality adds its bound Prompts to the system block
// after the Agent's own systemPrompt, agent-direct prompts, and project
// prompts — additive, never replacing.
model Personality {
id String @id @default(cuid())
name String
description String @default("")
agentId String
priority Int @default(5)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent @relation("AgentPersonalities", fields: [agentId], references: [id], onDelete: Cascade)
prompts PersonalityPrompt[]
defaultForAgent Agent[] @relation("AgentDefaultPersonality")
@@unique([name, agentId])
@@index([agentId])
}
// ── Personality ↔ Prompt join table ──
//
// A Prompt can be bound to many Personalities; a Personality can bind many
// Prompts. The `priority` here overrides the Prompt's own priority *within
// this Personality's overlay slice* (so the same prompt can be high-priority
// in one personality and low in another).
model PersonalityPrompt {
id String @id @default(cuid())
personalityId String
promptId String
priority Int @default(5)
createdAt DateTime @default(now())
personality Personality @relation(fields: [personalityId], references: [id], onDelete: Cascade)
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
@@unique([personalityId, promptId])
@@index([personalityId])
@@index([promptId])
}
// ── Chat Threads (persisted conversation per Agent) ──
model ChatThread {
id String @id @default(cuid())
// A thread is scoped to an Agent XOR a Project (enforced by a CHECK
// constraint in the migration + the service layer). Project-scoped threads
// back `mcpctl chat --project <name>`.
agentId String?
projectId String?
ownerId String
title String @default("")
lastTurnAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
messages ChatMessage[]
@@index([agentId, lastTurnAt(sort: Desc)])
@@index([projectId, lastTurnAt(sort: Desc)])
@@index([ownerId])
}
// ── Chat Messages ──
//
// `turnIndex` is monotonic per thread; the @@unique enforces ordering even
// under racing appends (callers retry on collision). `status` stays `pending`
// until the orchestrator confirms the turn completed successfully.
model ChatMessage {
id String @id @default(cuid())
threadId String
turnIndex Int
role String // 'system' | 'user' | 'assistant' | 'tool'
content String @db.Text
toolCalls Json? // assistant turn: [{id,name,arguments}]
toolCallId String? // tool turn: which call this answers
status String @default("complete") // 'pending' | 'complete' | 'error'
createdAt DateTime @default(now())
thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
@@unique([threadId, turnIndex])
@@index([threadId, createdAt])
}
// ── Inference Tasks (v5) ──
//
// Every inference call (sync infer, agent chat, async POST /inference-tasks)
// creates a row here. The DB is the source of truth; mcpd's previous
// in-memory `pendingTasks` map is gone — the result-handler updates the row
// and an in-process EventEmitter wakes any blocked HTTP handlers (single-
// instance for now; multi-instance scaling is a v6 concern that will swap
// the emitter for pg LISTEN/NOTIFY without changing the data model).
//
// Routing: `poolName` is the effective pool key at enqueue time
// (`Llm.poolName ?? Llm.name`). Workers (mcplocal sessions) drain pending
// rows whose `poolName` matches the pool keys they own when they bind their
// SSE channel — that's how queued tasks survive worker offline windows.
enum InferenceTaskStatus {
pending // in queue, no worker has it yet (or claim was reverted)
claimed // a worker has it (SSE frame sent), no chunks back yet
running // worker started streaming chunks back (streaming tasks only)
completed // worker POSTed the final result
error // permanent failure (auth, bad request, queue timeout)
cancelled // caller said never mind via DELETE
}
model InferenceTask {
id String @id @default(cuid())
status InferenceTaskStatus @default(pending)
// Routing — pool key drives worker matching at claim time. Stored at
// enqueue time so a later rename of Llm.poolName doesn't reroute
// already-queued work.
poolName String
llmName String // pinned target Llm name (for audit + agent backref)
model String
tier String?
// Worker tracking. NULL while pending; set on claim; cleared on
// unbindSession-driven revert (worker disconnect mid-task).
claimedBy String?
// Body + result. Both are Json so streaming chunks can be reconstructed
// (see TaskService.complete) and async pollers get a structured payload.
// requestBody is required (the OpenAI chat-completion request body the
// worker should run); responseBody is null until status=completed.
requestBody Json
responseBody Json?
errorMessage String?
/**
* Whether the original request asked for streaming. Drives the chunk-vs-
* final-body protocol on the result POST and tells async API callers
* whether `/stream` will yield chunks or just a single completion event.
*/
streaming Boolean @default(false)
// Timestamps for observability + GC:
// pending → claimed: claimedAt set
// claimed → running: streamStartedAt set (first chunk received)
// running/claimed → completed/error/cancelled: completedAt set
createdAt DateTime @default(now())
claimedAt DateTime?
streamStartedAt DateTime?
completedAt DateTime?
// Caller tracking — RBAC + observability. ownerId references User.id;
// agentId is set when the task came in via /agents/<name>/chat (null
// for direct /llms/<name>/infer or async POST /inference-tasks calls
// that don't pin an agent).
ownerId String
agentId String?
@@index([status, poolName])
@@index([claimedBy])
@@index([ownerId])
@@index([agentId])
// GC sweep predicate: completedAt < 7d ago. Index speeds up the daily
// cleanup without scanning the whole table.
@@index([completedAt])
}
// ── Audit Logs ──
model AuditLog {
id String @id @default(cuid())
userId String
action String
resource String
resourceId String?
details Json @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([action])
@@index([resource])
@@index([createdAt])
}