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
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>
890 lines
32 KiB
Plaintext
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])
|
|
}
|