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:
Michal
2026-05-07 00:18:21 +01:00
parent f8aa6c2f0d
commit fbe68fa693
6 changed files with 810 additions and 41 deletions

View File

@@ -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])