feat(db): schema for ResourceRevision, ResourceProposal, Skill
Phase 1 of the Skills + Revisions + Proposals work. Purely additive — no existing rows are touched, no tables renamed, no columns dropped. New tables: - ResourceRevision — append-only audit + diff log keyed by (resourceType, resourceId). Both Prompt and Skill produce revisions on every change. Soft FK so revisions outlive the resources they describe. Indexed for history viewer (latest-first), semver lookup, and cross-resource sync diff via contentHash. - ResourceProposal — generic propose/approve/reject queue. Drop-in replacement for the prompt-only PromptRequest. Created empty here; PR-2 will rename PromptRequest → _PromptRequest_legacy and backfill. - Skill — new resource type that mirrors Prompt for everything CRUD- shaped. Adds `files` Json (multi-file bundles, materialised onto disk by `mcpctl skills sync` in PR-5) and `metadata` Json (typed app-layer in PR-3: hooks, mcpServers, postInstall, …). New columns on Prompt: - semver (semver string, default '0.1.0') — auto-bumped patch on save by PromptService.update once PR-2 wires it. Distinct from `version`, which stays as the optimistic-concurrency counter. - currentRevisionId — soft pointer to the latest ResourceRevision row. DB tests cover scope rules (project XOR agent XOR neither), name uniqueness across both compound keys, cascade-on-delete, soft-FK survival of deletion, and JSON column persistence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -300,10 +300,12 @@ model Project {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
servers ProjectServer[]
|
||||
prompts Prompt[]
|
||||
promptRequests PromptRequest[]
|
||||
proposals ResourceProposal[]
|
||||
skills Skill[]
|
||||
mcpTokens McpToken[]
|
||||
agents Agent[]
|
||||
|
||||
@@ -386,18 +388,27 @@ enum InstanceStatus {
|
||||
// ── 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?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
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)
|
||||
@@ -409,6 +420,56 @@ model Prompt {
|
||||
@@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 {
|
||||
@@ -428,6 +489,91 @@ model PromptRequest {
|
||||
@@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 {
|
||||
@@ -499,13 +645,15 @@ model Agent {
|
||||
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)
|
||||
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[]
|
||||
personalities Personality[] @relation("AgentPersonalities")
|
||||
defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)
|
||||
proposals ResourceProposal[]
|
||||
skills Skill[]
|
||||
personalities Personality[] @relation("AgentPersonalities")
|
||||
defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([name])
|
||||
@@index([llmId])
|
||||
@@ -619,54 +767,54 @@ model ChatMessage {
|
||||
// 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
|
||||
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)
|
||||
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?
|
||||
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?
|
||||
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?
|
||||
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)
|
||||
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?
|
||||
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?
|
||||
ownerId String
|
||||
agentId String?
|
||||
|
||||
@@index([status, poolName])
|
||||
@@index([claimedBy])
|
||||
|
||||
Reference in New Issue
Block a user