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:`, 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:`, `run:llms:`). 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//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//) ── // // Skills are the on-disk counterpart to Prompts. mcpd is the source of truth; // mcpctl skills sync materialises them onto disk under // ~/.claude/skills//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-/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 `. 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//chat (null // for direct /llms//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]) }