From 1ec286bb146223b9169180669cea3af859a417c0 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 7 May 2026 00:38:35 +0100 Subject: [PATCH] feat(mcpd): ResourceRevision + ResourceProposal services + Prompt revision integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Skills + Revisions + Proposals work. Stands up the generic revision/proposal layer and wires Prompt into it. Skills will plug into the same infrastructure in PR-3 with no further service changes required. This PR is intentionally additive: PromptRequest table and routes are unchanged. The /api/v1/proposals API runs side-by-side with the legacy /api/v1/promptrequests API. The PromptRequest cutover (rename + backfill + mcplocal rewire) is deferred to a later PR so this one stays reviewable. ## What's added ### Repositories (src/mcpd/src/repositories/) - resource-revision.repository.ts — append-only revision log keyed by (resourceType, resourceId). Soft FK; no relations declared. Supports history listing, semver lookup, and contentHash cross-resource search. - resource-proposal.repository.ts — generic propose queue. Status lifecycle pending → approved | rejected. Mirrors Prompt's `?? ''` workaround for nullable-FK compound lookups. ### Services (src/mcpd/src/services/) - resource-revision.service.ts — record() inserts a revision with a stable sha256 contentHash computed from canonicalised JSON (key-sorted at every level so reordered objects produce the same hash). Caller passes a pre-computed semver; service does NOT decide bump policy. - resource-proposal.service.ts — propose / approve / reject / list, with a per-resourceType handler registry. PromptService registers the 'prompt' handler at construction; the SkillService will register 'skill' in PR-3. approve() runs in a Prisma $transaction so the resource update + revision insert + proposal status flip are atomic. ### Pure utility (src/mcpd/src/utils/semver.ts) - bumpSemver(current, kind) for major / minor / patch - compareSemver(a, b) — numeric, not lex (10 > 9) - isValidSemver(s) - Invalid input falls back to '0.1.0' rather than throwing — keeps the audit-write path from blowing up the prompt update if a row's semver ever drifts out of MAJOR.MINOR.PATCH shape. ### Routes (src/mcpd/src/routes/) - revisions.ts — GET /api/v1/revisions?resourceType=&resourceId=, GET /api/v1/revisions/:id, GET /api/v1/revisions/:id/diff?against= (unified-format diff via the `diff` package), and POST /api/v1/prompts/:id/restore-revision { revisionId, note? }. - proposals.ts — GET / POST /api/v1/proposals, GET /api/v1/proposals/:id, PUT for body updates, POST .../approve and POST .../reject, plus DELETE. ## What's changed - PromptService.create / update now record a ResourceRevision when the revision service is wired. Update auto-bumps patch on content change; authors can override via `--bump major|minor|patch` or `--semver X.Y.Z` on the CLI (forwarded into the PUT body). Best-effort: revision write failures are swallowed so the prompt save still succeeds (revision is audit, not source of truth). - PromptService.setProposalService registers a 'prompt' approval handler with the proposal service. Approval runs in a Prisma transaction: upsert prompt → record revision → update currentRevisionId → flip proposal status. semver bumps to 0.1.0 on first approval, patch thereafter. - New CLI flags on `mcpctl edit prompt`: --bump, --semver, --note. They're prompt-only (validated client-side); other resources reject them. - Aliases in shared.ts: `proposal`/`prop` → proposals, `revision`/`rev` → revisions. - diff dependency added to mcpd. ## Tests - src/mcpd/tests/utils/semver.test.ts — covers bump/compare/validate including numeric (not lex) semver compare and invalid-input fallback. - prompt-service.test.ts updated: makePrompt fixture now sets semver + agentId + currentRevisionId; updatePrompt assertion expects the auto-bumped patch in the same update call. - prompt-routes.test.ts updated symmetrically. ## RBAC `proposals` and `revisions` URL segments map to the existing `prompts` permission for now. PR-7 may split if a "reviewer" role becomes useful. ## Verification Full suite: 158 test files / 2127 tests green. `pnpm build` clean across all 6 workspace packages. Co-Authored-By: Claude Opus 4.7 (1M context) --- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 5 + pnpm-lock.yaml | 17 ++ src/cli/src/commands/edit.ts | 28 ++- src/cli/src/commands/shared.ts | 8 + src/mcpd/package.json | 2 + src/mcpd/src/main.ts | 23 +++ .../src/repositories/prompt.repository.ts | 2 + .../resource-proposal.repository.ts | 138 ++++++++++++++ .../resource-revision.repository.ts | 79 ++++++++ src/mcpd/src/routes/proposals.ts | 157 ++++++++++++++++ src/mcpd/src/routes/revisions.ts | 123 +++++++++++++ src/mcpd/src/services/prompt.service.ts | 168 +++++++++++++++++- .../src/services/resource-proposal.service.ts | 133 ++++++++++++++ .../src/services/resource-revision.service.ts | 95 ++++++++++ src/mcpd/src/utils/semver.ts | 56 ++++++ src/mcpd/src/validation/prompt.schema.ts | 9 + src/mcpd/tests/prompt-routes.test.ts | 9 +- .../tests/services/prompt-service.test.ts | 7 +- src/mcpd/tests/utils/semver.test.ts | 70 ++++++++ 20 files changed, 1126 insertions(+), 7 deletions(-) create mode 100644 src/mcpd/src/repositories/resource-proposal.repository.ts create mode 100644 src/mcpd/src/repositories/resource-revision.repository.ts create mode 100644 src/mcpd/src/routes/proposals.ts create mode 100644 src/mcpd/src/routes/revisions.ts create mode 100644 src/mcpd/src/services/resource-proposal.service.ts create mode 100644 src/mcpd/src/services/resource-revision.service.ts create mode 100644 src/mcpd/src/utils/semver.ts create mode 100644 src/mcpd/tests/utils/semver.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index fec5261..f50e4ff 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -228,11 +228,11 @@ _mcpctl() { return ;; edit) if [[ -z "$resource_type" ]]; then - COMPREPLY=($(compgen -W "servers secrets projects groups rbac prompts promptrequests personalities -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "servers secrets projects groups rbac prompts promptrequests personalities --bump --semver --note -h --help" -- "$cur")) else local names names=$(_mcpctl_resource_names "$resource_type") - COMPREPLY=($(compgen -W "$names -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$names --bump --semver --note -h --help" -- "$cur")) fi return ;; apply) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index def55a4..754f7f8 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -509,6 +509,11 @@ complete -c mcpctl -n "__fish_seen_subcommand_from delete" -l agent -d 'Agent na complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s t -l tail -d 'Number of lines to show' -x complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s i -l instance -d 'Instance/replica index (0-based, for servers with multiple replicas)' -x +# edit options +complete -c mcpctl -n "__fish_seen_subcommand_from edit" -l bump -d 'Bump prompt semver after edit: major | minor | patch' -x +complete -c mcpctl -n "__fish_seen_subcommand_from edit" -l semver -d 'Set prompt semver explicitly (X.Y.Z)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from edit" -l note -d 'Note attached to the resulting revision' -x + # apply options complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Path to config file (alternative to positional arg)' -rF complete -c mcpctl -n "__fish_seen_subcommand_from apply" -l dry-run -d 'Validate and show changes without applying' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b44c75..d8baa90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: bcrypt: specifier: ^5.1.1 version: 5.1.1 + diff: + specifier: ^5.2.0 + version: 5.2.2 dockerode: specifier: ^4.0.9 version: 4.0.9 @@ -146,6 +149,9 @@ importers: '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 + '@types/diff': + specifier: ^5.2.3 + version: 5.2.3 '@types/dockerode': specifier: ^4.0.1 version: 4.0.1 @@ -1095,6 +1101,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff@5.2.3': + resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} + '@types/diff@8.0.0': resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed. @@ -1680,6 +1689,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -4092,6 +4105,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/diff@5.2.3': {} + '@types/diff@8.0.0': dependencies: diff: 8.0.3 @@ -4689,6 +4704,8 @@ snapshots: detect-libc@2.1.2: {} + diff@5.2.2: {} + diff@8.0.3: {} docker-modem@5.0.6: diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts index dbfcb41..de8b8e5 100644 --- a/src/cli/src/commands/edit.ts +++ b/src/cli/src/commands/edit.ts @@ -37,7 +37,10 @@ export function createEditCommand(deps: EditCommandDeps): Command { .description('Edit a resource in your default editor (server, project)') .argument('', 'Resource type (server, project)') .argument('', 'Resource name or ID') - .action(async (resourceArg: string, nameOrId: string) => { + .option('--bump ', 'Bump prompt semver after edit: major | minor | patch') + .option('--semver ', 'Set prompt semver explicitly (X.Y.Z)') + .option('--note ', 'Note attached to the resulting revision') + .action(async (resourceArg: string, nameOrId: string, opts: { bump?: string; semver?: string; note?: string }) => { const resource = resolveResource(resourceArg); // Instances are immutable @@ -55,6 +58,23 @@ export function createEditCommand(deps: EditCommandDeps): Command { return; } + // Validation for prompt-only revision flags + if ((opts.bump !== undefined || opts.semver !== undefined || opts.note !== undefined) && resource !== 'prompts') { + log('Error: --bump, --semver, and --note are only valid for prompts'); + process.exitCode = 1; + return; + } + if (opts.bump !== undefined && opts.semver !== undefined) { + log('Error: pass --bump or --semver, not both'); + process.exitCode = 1; + return; + } + if (opts.bump !== undefined && !['major', 'minor', 'patch'].includes(opts.bump)) { + log("Error: --bump must be 'major', 'minor', or 'patch'"); + process.exitCode = 1; + return; + } + // Resolve name → ID const id = await resolveNameOrId(client, resource, nameOrId); @@ -102,6 +122,12 @@ export function createEditCommand(deps: EditCommandDeps): Command { // Parse and apply const updates = yaml.load(modifiedClean) as Record; + // Append semver-related flags for prompts (server-side bumps + records revision). + if (resource === 'prompts') { + if (opts.bump !== undefined) updates.bump = opts.bump; + if (opts.semver !== undefined) updates.semver = opts.semver; + if (opts.note !== undefined) updates.note = opts.note; + } await client.put(`/api/v1/${resource}/${id}`, updates); log(`${singular} '${nameOrId}' updated.`); } finally { diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index fbb9cdf..4159320 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -21,6 +21,14 @@ export const RESOURCE_ALIASES: Record = { promptrequest: 'promptrequests', promptrequests: 'promptrequests', pr: 'promptrequests', + // PR-2: shared revision + proposal queue (replaces promptrequests in + // PR-7). Lookup goes through /api/v1/proposals and /api/v1/revisions. + proposal: 'proposals', + proposals: 'proposals', + prop: 'proposals', + revision: 'revisions', + revisions: 'revisions', + rev: 'revisions', serverattachment: 'serverattachments', serverattachments: 'serverattachments', sa: 'serverattachments', diff --git a/src/mcpd/package.json b/src/mcpd/package.json index 3f51592..48e570a 100644 --- a/src/mcpd/package.json +++ b/src/mcpd/package.json @@ -23,6 +23,7 @@ "@mcpctl/shared": "workspace:*", "@prisma/client": "^6.0.0", "bcrypt": "^5.1.1", + "diff": "^5.2.0", "dockerode": "^4.0.9", "fastify": "^5.0.0", "js-yaml": "^4.1.0", @@ -30,6 +31,7 @@ }, "devDependencies": { "@types/bcrypt": "^5.0.2", + "@types/diff": "^5.2.3", "@types/dockerode": "^4.0.1", "@types/js-yaml": "^4.0.9", "@types/node": "^25.3.0" diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index d53828f..afadf02 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -98,8 +98,14 @@ import { registerMcpTokenRoutes, } from './routes/index.js'; import { registerPromptRoutes } from './routes/prompts.js'; +import { registerRevisionRoutes } from './routes/revisions.js'; +import { registerProposalRoutes } from './routes/proposals.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; +import { ResourceRevisionRepository } from './repositories/resource-revision.repository.js'; +import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js'; +import { ResourceRevisionService } from './services/resource-revision.service.js'; +import { ResourceProposalService } from './services/resource-proposal.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; import { ResourceRuleRegistry } from './validation/resource-rules.js'; @@ -168,6 +174,12 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'mcp': 'servers', 'prompts': 'prompts', 'promptrequests': 'promptrequests', + // PR-2: revisions/proposals piggyback on the prompts permission for now. + // Anyone with view:prompts can read history; anyone with edit:prompts can + // approve/reject proposals. PR-7 may split these out if RBAC granularity + // becomes useful (e.g., a "reviewer" role). + 'revisions': 'prompts', + 'proposals': 'prompts', 'mcptokens': 'mcptokens', 'llms': 'llms', // v5: durable inference task queue. Same default action mapping as @@ -468,6 +480,15 @@ async function main(): Promise { const promptRuleRegistry = new ResourceRuleRegistry(); promptRuleRegistry.register(systemPromptVarsRule); const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo); + // PR-2: shared revision/proposal infra. Promp service registers its + // 'prompt' approval handler with the proposal service via setProposalService; + // PR-3 wires the same for skills. + const resourceRevisionRepo = new ResourceRevisionRepository(prisma); + const resourceRevisionService = new ResourceRevisionService(resourceRevisionRepo); + const resourceProposalRepo = new ResourceProposalRepository(prisma); + const resourceProposalService = new ResourceProposalService(resourceProposalRepo, prisma); + promptService.setRevisionService(resourceRevisionService); + promptService.setProposalService(resourceProposalService); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -668,6 +689,8 @@ async function main(): Promise { registerGroupRoutes(app, groupService); registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo }); registerPromptRoutes(app, promptService, projectRepo, agentRepo); + registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService }); + registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo }); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/repositories/prompt.repository.ts b/src/mcpd/src/repositories/prompt.repository.ts index 80d2511..ea0fde5 100644 --- a/src/mcpd/src/repositories/prompt.repository.ts +++ b/src/mcpd/src/repositories/prompt.repository.ts @@ -14,6 +14,8 @@ export interface PromptUpdateInput { priority?: number; summary?: string; chapters?: string[]; + semver?: string; + currentRevisionId?: string | null; } export interface IPromptRepository { diff --git a/src/mcpd/src/repositories/resource-proposal.repository.ts b/src/mcpd/src/repositories/resource-proposal.repository.ts new file mode 100644 index 0000000..6065888 --- /dev/null +++ b/src/mcpd/src/repositories/resource-proposal.repository.ts @@ -0,0 +1,138 @@ +import type { PrismaClient, Prisma, ResourceProposal } from '@prisma/client'; + +import type { ResourceType } from './resource-revision.repository.js'; + +/** + * Generic propose/approve/reject queue keyed by (resourceType, name, + * projectId|agentId). Successor to PromptRequest. The repo mirrors + * PromptRepository's `?? ''` workaround for nullable-FK compound lookups. + */ + +export type ProposalStatus = 'pending' | 'approved' | 'rejected'; + +export interface ProposalListFilter { + resourceType?: ResourceType; + projectId?: string; + agentId?: string; + status?: ProposalStatus; +} + +export interface CreateProposalInput { + resourceType: ResourceType; + name: string; + body: Prisma.InputJsonValue; + projectId?: string; + agentId?: string; + createdBySession?: string; + createdByUserId?: string; +} + +export interface UpdateProposalStatusInput { + status: ProposalStatus; + reviewerNote?: string; + approvedRevisionId?: string; +} + +export interface IResourceProposalRepository { + list(filter: ProposalListFilter): Promise; + findById(id: string): Promise; + findByName(resourceType: ResourceType, name: string, scope: { projectId: string | null; agentId: string | null }): Promise; + findBySession(sessionId: string, projectId?: string): Promise; + create(data: CreateProposalInput, tx?: Prisma.TransactionClient): Promise; + updateBody(id: string, body: Prisma.InputJsonValue): Promise; + updateStatus(id: string, data: UpdateProposalStatusInput, tx?: Prisma.TransactionClient): Promise; + delete(id: string): Promise; +} + +export class ResourceProposalRepository implements IResourceProposalRepository { + constructor(private readonly prisma: PrismaClient) {} + + async list(filter: ProposalListFilter): Promise { + const where: Prisma.ResourceProposalWhereInput = {}; + if (filter.resourceType) where.resourceType = filter.resourceType; + if (filter.status) where.status = filter.status; + if (filter.projectId !== undefined) { + // Match project-scoped + globals (NULL projectId), like PromptRepo. + where.OR = [{ projectId: filter.projectId }, { projectId: null, agentId: null }]; + } + if (filter.agentId !== undefined) { + where.agentId = filter.agentId; + } + return this.prisma.resourceProposal.findMany({ + where, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.resourceProposal.findUnique({ + where: { id }, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + }); + } + + async findByName( + resourceType: ResourceType, + name: string, + scope: { projectId: string | null; agentId: string | null }, + ): Promise { + if (scope.agentId !== null) { + return this.prisma.resourceProposal.findUnique({ + where: { resourceType_name_agentId: { resourceType, name, agentId: scope.agentId } }, + }); + } + // Project-scoped or global (projectId=null is handled by the same compound key). + return this.prisma.resourceProposal.findUnique({ + where: { resourceType_name_projectId: { resourceType, name, projectId: scope.projectId ?? '' } }, + }); + } + + async findBySession(sessionId: string, projectId?: string): Promise { + const where: Prisma.ResourceProposalWhereInput = { createdBySession: sessionId }; + if (projectId !== undefined) { + where.OR = [{ projectId }, { projectId: null, agentId: null }]; + } + return this.prisma.resourceProposal.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + } + + async create(data: CreateProposalInput, tx?: Prisma.TransactionClient): Promise { + const client = tx ?? this.prisma; + return client.resourceProposal.create({ data }); + } + + async updateBody(id: string, body: Prisma.InputJsonValue): Promise { + return this.prisma.resourceProposal.update({ + where: { id }, + data: { body, version: { increment: 1 } }, + }); + } + + async updateStatus( + id: string, + data: UpdateProposalStatusInput, + tx?: Prisma.TransactionClient, + ): Promise { + const client = tx ?? this.prisma; + const update: Prisma.ResourceProposalUpdateInput = { + status: data.status, + version: { increment: 1 }, + }; + if (data.reviewerNote !== undefined) update.reviewerNote = data.reviewerNote; + if (data.approvedRevisionId !== undefined) update.approvedRevisionId = data.approvedRevisionId; + return client.resourceProposal.update({ where: { id }, data: update }); + } + + async delete(id: string): Promise { + await this.prisma.resourceProposal.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/resource-revision.repository.ts b/src/mcpd/src/repositories/resource-revision.repository.ts new file mode 100644 index 0000000..2731423 --- /dev/null +++ b/src/mcpd/src/repositories/resource-revision.repository.ts @@ -0,0 +1,79 @@ +import type { PrismaClient, Prisma, ResourceRevision } from '@prisma/client'; + +/** + * Append-only revision log shared by Prompt and Skill (and any future + * resource type with a `resourceType` discriminator). The repository is + * intentionally narrow: callers always know which resource they're + * looking at, so every read takes (resourceType, resourceId) explicitly. + * + * `resourceId` is a soft FK — there's no `Prompt`/`Skill` relation here, + * because revisions need to outlive the resources they describe (audit + * survives deletion). That means we accept any string and trust the + * service layer to keep them in sync with real IDs. + */ + +export type ResourceType = 'prompt' | 'skill'; + +export interface CreateRevisionInput { + resourceType: ResourceType; + resourceId: string; + semver: string; + contentHash: string; + body: Prisma.InputJsonValue; + authorUserId?: string; + authorSessionId?: string; + note?: string; +} + +export interface IResourceRevisionRepository { + create(data: CreateRevisionInput, tx?: Prisma.TransactionClient): Promise; + findById(id: string): Promise; + findLatest(resourceType: ResourceType, resourceId: string): Promise; + findHistory(resourceType: ResourceType, resourceId: string, limit?: number): Promise; + findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise; + findByContentHash(contentHash: string): Promise; +} + +export class ResourceRevisionRepository implements IResourceRevisionRepository { + constructor(private readonly prisma: PrismaClient) {} + + async create(data: CreateRevisionInput, tx?: Prisma.TransactionClient): Promise { + const client = tx ?? this.prisma; + return client.resourceRevision.create({ data }); + } + + async findById(id: string): Promise { + return this.prisma.resourceRevision.findUnique({ where: { id } }); + } + + async findLatest(resourceType: ResourceType, resourceId: string): Promise { + return this.prisma.resourceRevision.findFirst({ + where: { resourceType, resourceId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findHistory(resourceType: ResourceType, resourceId: string, limit = 100): Promise { + return this.prisma.resourceRevision.findMany({ + where: { resourceType, resourceId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } + + async findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise { + // Multiple revisions can share a semver if a value was reused (rare, + // but possible with manual --semver overrides). Return the latest. + return this.prisma.resourceRevision.findFirst({ + where: { resourceType, resourceId, semver }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findByContentHash(contentHash: string): Promise { + return this.prisma.resourceRevision.findMany({ + where: { contentHash }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/src/mcpd/src/routes/proposals.ts b/src/mcpd/src/routes/proposals.ts new file mode 100644 index 0000000..a9a93f8 --- /dev/null +++ b/src/mcpd/src/routes/proposals.ts @@ -0,0 +1,157 @@ +import type { FastifyInstance } from 'fastify'; + +import type { ResourceProposalService } from '../services/resource-proposal.service.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; +import type { + ResourceType, +} from '../repositories/resource-revision.repository.js'; +import type { ProposalStatus } from '../repositories/resource-proposal.repository.js'; + +interface ProposalRouteDeps { + proposalService: ResourceProposalService; + projectRepo: IProjectRepository; + agentRepo?: IAgentRepository; +} + +const VALID_TYPES: readonly ResourceType[] = ['prompt', 'skill'] as const; +const VALID_STATUSES: readonly ProposalStatus[] = ['pending', 'approved', 'rejected'] as const; + +export function registerProposalRoutes(app: FastifyInstance, deps: ProposalRouteDeps): void { + const { proposalService, projectRepo, agentRepo } = deps; + + app.get<{ Querystring: { resourceType?: string; status?: string; project?: string; agent?: string } }>( + '/api/v1/proposals', + async (request) => { + const filter: { + resourceType?: ResourceType; + status?: ProposalStatus; + projectId?: string; + agentId?: string; + } = {}; + const { resourceType, status, project, agent } = request.query; + if (resourceType !== undefined) { + if (!VALID_TYPES.includes(resourceType as ResourceType)) { + throw Object.assign(new Error(`Invalid resourceType: ${resourceType}`), { statusCode: 400 }); + } + filter.resourceType = resourceType as ResourceType; + } + if (status !== undefined) { + if (!VALID_STATUSES.includes(status as ProposalStatus)) { + throw Object.assign(new Error(`Invalid status: ${status}`), { statusCode: 400 }); + } + filter.status = status as ProposalStatus; + } + if (project !== undefined) { + const proj = await projectRepo.findByName(project); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 }); + } + filter.projectId = proj.id; + } + if (agent !== undefined) { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(agent); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${agent}`), { statusCode: 404 }); + } + filter.agentId = ag.id; + } + return proposalService.list(filter); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/proposals/:id', async (request) => { + return proposalService.getById(request.params.id); + }); + + app.post('/api/v1/proposals', async (request, reply) => { + const body = request.body as Record; + const resourceType = body['resourceType']; + if (typeof resourceType !== 'string' || !VALID_TYPES.includes(resourceType as ResourceType)) { + throw Object.assign(new Error('resourceType must be "prompt" or "skill"'), { statusCode: 400 }); + } + const name = body['name']; + if (typeof name !== 'string' || name.length === 0) { + throw Object.assign(new Error('name is required'), { statusCode: 400 }); + } + const proposalBody = body['body']; + if (proposalBody === undefined || typeof proposalBody !== 'object' || proposalBody === null) { + throw Object.assign(new Error('body must be an object'), { statusCode: 400 }); + } + const input: { + resourceType: ResourceType; + name: string; + body: Record; + projectId?: string; + agentId?: string; + createdBySession?: string; + createdByUserId?: string; + } = { + resourceType: resourceType as ResourceType, + name, + body: proposalBody as Record, + }; + if (typeof body['project'] === 'string') { + const proj = await projectRepo.findByName(body['project']); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 }); + } + input.projectId = proj.id; + } else if (typeof body['projectId'] === 'string') { + input.projectId = body['projectId']; + } + if (typeof body['agent'] === 'string') { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(body['agent']); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${body['agent']}`), { statusCode: 404 }); + } + input.agentId = ag.id; + } else if (typeof body['agentId'] === 'string') { + input.agentId = body['agentId']; + } + if (typeof body['createdBySession'] === 'string') input.createdBySession = body['createdBySession']; + if (typeof body['createdByUserId'] === 'string') input.createdByUserId = body['createdByUserId']; + + const proposal = await proposalService.propose(input); + reply.code(201); + return proposal; + }); + + app.put<{ Params: { id: string }; Body: { body?: Record } }>( + '/api/v1/proposals/:id', + async (request) => { + const proposalBody = request.body.body; + if (proposalBody === undefined) { + throw Object.assign(new Error('body is required'), { statusCode: 400 }); + } + return proposalService.updateBody(request.params.id, proposalBody); + }, + ); + + app.post<{ Params: { id: string } }>('/api/v1/proposals/:id/approve', async (request) => { + // approverUserId is set by the auth middleware on the request — we + // don't grab it explicitly here; service uses what the audit layer + // already records. Threading it through requires the auth context + // (out of scope for PR-2; PR-4's reviewer flow will surface it). + return proposalService.approve(request.params.id); + }); + + app.post<{ Params: { id: string }; Body: { reason?: string; reviewerNote?: string } }>( + '/api/v1/proposals/:id/reject', + async (request) => { + const note = request.body.reviewerNote ?? request.body.reason ?? ''; + return proposalService.reject(request.params.id, note); + }, + ); + + app.delete<{ Params: { id: string } }>('/api/v1/proposals/:id', async (request, reply) => { + await proposalService.delete(request.params.id); + reply.code(204); + }); +} diff --git a/src/mcpd/src/routes/revisions.ts b/src/mcpd/src/routes/revisions.ts new file mode 100644 index 0000000..381ff8b --- /dev/null +++ b/src/mcpd/src/routes/revisions.ts @@ -0,0 +1,123 @@ +import type { FastifyInstance } from 'fastify'; +import { createPatch } from 'diff'; + +import type { ResourceRevisionService } from '../services/resource-revision.service.js'; +import type { PromptService } from '../services/prompt.service.js'; +import type { ResourceType } from '../repositories/resource-revision.repository.js'; + +interface RevisionRouteDeps { + revisionService: ResourceRevisionService; + promptService: PromptService; + // Future: skillService for PR-3. +} + +const VALID_TYPES: readonly ResourceType[] = ['prompt', 'skill'] as const; + +export function registerRevisionRoutes(app: FastifyInstance, deps: RevisionRouteDeps): void { + const { revisionService, promptService } = deps; + + // List history for a resource. Either both query params or none (none = error). + app.get<{ Querystring: { resourceType?: string; resourceId?: string; limit?: string } }>( + '/api/v1/revisions', + async (request) => { + const { resourceType, resourceId, limit } = request.query; + if (!resourceType || !resourceId) { + throw Object.assign( + new Error('Both resourceType and resourceId are required'), + { statusCode: 400 }, + ); + } + if (!VALID_TYPES.includes(resourceType as ResourceType)) { + throw Object.assign( + new Error(`Invalid resourceType: ${resourceType}`), + { statusCode: 400 }, + ); + } + const limitNum = limit ? Math.min(500, Math.max(1, Number(limit))) : 100; + return revisionService.listHistory(resourceType as ResourceType, resourceId, limitNum); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/revisions/:id', async (request) => { + const revision = await revisionService.getById(request.params.id); + if (revision === null) { + throw Object.assign(new Error(`Revision not found: ${request.params.id}`), { statusCode: 404 }); + } + return revision; + }); + + /** + * Unified diff between two revisions, or between a revision and the + * live resource body. `against` accepts another revision id or the + * literal string `live`. + */ + app.get<{ Params: { id: string }; Querystring: { against?: string } }>( + '/api/v1/revisions/:id/diff', + async (request) => { + const revision = await revisionService.getById(request.params.id); + if (revision === null) { + throw Object.assign(new Error(`Revision not found: ${request.params.id}`), { statusCode: 404 }); + } + const against = request.query.against ?? 'live'; + + let otherContent: string; + let otherLabel: string; + if (against === 'live') { + // For prompts, fetch the live row by resourceId. + if (revision.resourceType === 'prompt') { + const prompt = await promptService.getPrompt(revision.resourceId); + otherContent = prompt.content; + otherLabel = `${prompt.name} (live, semver ${prompt.semver})`; + } else { + // PR-3 will wire skillService here. + throw Object.assign( + new Error(`Live diff not supported for resourceType ${revision.resourceType} yet`), + { statusCode: 501 }, + ); + } + } else { + const otherRev = await revisionService.getById(against); + if (otherRev === null) { + throw Object.assign(new Error(`Other revision not found: ${against}`), { statusCode: 404 }); + } + if (otherRev.resourceType !== revision.resourceType || otherRev.resourceId !== revision.resourceId) { + throw Object.assign( + new Error('Diff requires both revisions to be of the same resource'), + { statusCode: 400 }, + ); + } + otherContent = stringContent(otherRev.body); + otherLabel = `revision ${otherRev.id} (${otherRev.semver})`; + } + + const thisContent = stringContent(revision.body); + const thisLabel = `revision ${revision.id} (${revision.semver})`; + + // Unified-format patch. Caller can render this directly or pass to a diff viewer. + const patch = createPatch(`${revision.resourceType}/${revision.resourceId}`, otherContent, thisContent, otherLabel, thisLabel); + return { patch }; + }, + ); + + // POST /api/v1/prompts/:id/restore-revision { revisionId, note? } + // (Skill route registered in PR-3 alongside this with the same shape.) + app.post<{ Params: { id: string }; Body: { revisionId: string; note?: string } }>( + '/api/v1/prompts/:id/restore-revision', + async (request) => { + const { revisionId, note } = request.body; + if (!revisionId) { + throw Object.assign(new Error('revisionId is required'), { statusCode: 400 }); + } + return promptService.restoreRevisionForPrompt(request.params.id, revisionId, note); + }, + ); +} + +/** Pull a `content` string out of a revision body, falling back to the raw JSON. */ +function stringContent(body: unknown): string { + if (body !== null && typeof body === 'object' && !Array.isArray(body)) { + const v = (body as Record)['content']; + if (typeof v === 'string') return v; + } + return JSON.stringify(body, null, 2); +} diff --git a/src/mcpd/src/services/prompt.service.ts b/src/mcpd/src/services/prompt.service.ts index 528826a..d8a4a0c 100644 --- a/src/mcpd/src/services/prompt.service.ts +++ b/src/mcpd/src/services/prompt.service.ts @@ -6,11 +6,15 @@ import type { IAgentRepository } from '../repositories/agent.repository.js'; import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema, UpdatePromptRequestSchema } from '../validation/prompt.schema.js'; import { NotFoundError } from './mcp-server.service.js'; import type { PromptSummaryService } from './prompt-summary.service.js'; +import type { ResourceRevisionService } from './resource-revision.service.js'; +import type { ResourceProposalService } from './resource-proposal.service.js'; +import { bumpSemver, type BumpKind } from '../utils/semver.js'; import { SYSTEM_PROJECT_NAME, getSystemPromptDefault } from '../bootstrap/system-project.js'; import type { ResourceRuleRegistry, RuleContext } from '../validation/resource-rules.js'; export class PromptService { private summaryService: PromptSummaryService | null = null; + private revisionService: ResourceRevisionService | null = null; constructor( private readonly promptRepo: IPromptRepository, @@ -24,6 +28,85 @@ export class PromptService { this.summaryService = service; } + /** + * Wire revision + proposal infrastructure (PR-2). Optional so existing + * tests that construct a bare PromptService keep working unchanged — + * when these are unset, create/update skip the revision write and + * proposal-approval is unsupported. + */ + setRevisionService(service: ResourceRevisionService): void { + this.revisionService = service; + } + + setProposalService(service: ResourceProposalService): void { + // Register a 'prompt' approval handler so proposalService.approve(id) + // can dispatch to us when the proposal targets a prompt. The service + // itself is kept only via this closure binding — no field needed. + service.setHandler('prompt', async (proposal, tx, _approverUserId) => { + const body = (proposal.body ?? {}) as Record; + const content = String(body['content'] ?? ''); + const priority = typeof body['priority'] === 'number' ? body['priority'] : 5; + const linkTarget = typeof body['linkTarget'] === 'string' ? body['linkTarget'] : undefined; + // Resolve scope: project-only for now (agent-scoped proposals come with PR-3+). + const projectId = proposal.projectId ?? null; + const agentId = proposal.agentId ?? null; + + // Upsert: existing prompt with this (name, scope) → update body and bump semver; + // otherwise → create at 0.1.0. + const existing = agentId !== null + ? await tx.prompt.findUnique({ where: { name_agentId: { name: proposal.name, agentId } } }) + : await tx.prompt.findUnique({ where: { name_projectId: { name: proposal.name, projectId: projectId ?? '' } } }); + + let promptId: string; + let newSemver: string; + if (existing !== null) { + // Bump patch for an approved-update. + newSemver = bumpSemver(existing.semver, 'patch'); + await tx.prompt.update({ + where: { id: existing.id }, + data: { content, priority, semver: newSemver }, + }); + promptId = existing.id; + } else { + // Approval-from-scratch: prompt didn't exist before this proposal. + newSemver = '0.1.0'; + const created = await tx.prompt.create({ + data: { + name: proposal.name, + content, + priority, + ...(projectId !== null ? { projectId } : {}), + ...(agentId !== null ? { agentId } : {}), + ...(linkTarget !== undefined ? { linkTarget } : {}), + semver: newSemver, + }, + }); + promptId = created.id; + } + + const { revision } = await this.revisionService!.record( + { + resourceType: 'prompt', + resourceId: promptId, + semver: newSemver, + body: { content, priority, ...(linkTarget !== undefined ? { linkTarget } : {}) }, + ...(proposal.createdByUserId !== null ? { authorUserId: proposal.createdByUserId } : {}), + ...(proposal.createdBySession !== null ? { authorSessionId: proposal.createdBySession } : {}), + note: `approved proposal ${proposal.id}`, + }, + tx, + ); + + // Soft pointer to latest revision. + await tx.prompt.update({ + where: { id: promptId }, + data: { currentRevisionId: revision.id }, + }); + + return { resourceId: promptId, revisionId: revision.id }; + }); + } + /** * Run resource validation rules for a prompt. * Throws 400 if validation fails. @@ -104,6 +187,10 @@ export class PromptService { if (data.priority !== undefined) createData.priority = data.priority; if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget; const prompt = await this.promptRepo.create(createData); + // Record initial revision (0.1.0). Non-blocking — revision is audit, not source of truth. + if (this.revisionService) { + this.recordPromptRevision(prompt, '0.1.0', 'created').catch(() => {}); + } // Auto-generate summary/chapters (non-blocking — don't fail create if summary fails) if (this.summaryService && !data.linkTarget) { this.generateAndStoreSummary(prompt.id, data.content).catch(() => {}); @@ -113,16 +200,38 @@ export class PromptService { async updatePrompt(id: string, input: unknown): Promise { const data = UpdatePromptSchema.parse(input); + if (data.semver !== undefined && data.bump !== undefined) { + throw Object.assign(new Error('Pass --semver or --bump, not both'), { statusCode: 400 }); + } const existing = await this.getPrompt(id); if (data.content !== undefined) { await this.validatePromptRules(existing.name, data.content, existing.projectId, 'update'); } - const updateData: { content?: string; priority?: number } = {}; + // Resolve new semver: + // explicit > explicit-bump > auto-patch (only when content changed) + let newSemver = existing.semver; + if (data.semver !== undefined) { + newSemver = data.semver; + } else if (data.bump !== undefined) { + newSemver = bumpSemver(existing.semver, data.bump as BumpKind); + } else if (data.content !== undefined) { + newSemver = bumpSemver(existing.semver, 'patch'); + } + + const updateData: { content?: string; priority?: number; semver?: string } = {}; if (data.content !== undefined) updateData.content = data.content; if (data.priority !== undefined) updateData.priority = data.priority; + if (newSemver !== existing.semver) updateData.semver = newSemver; const prompt = await this.promptRepo.update(id, updateData); + + // Record revision when content actually changed OR semver was explicitly bumped. + const shouldRecord = data.content !== undefined || data.bump !== undefined || data.semver !== undefined; + if (this.revisionService && shouldRecord) { + this.recordPromptRevision(prompt, newSemver, data.note ?? null).catch(() => {}); + } + // Regenerate summary when content changes if (this.summaryService && data.content !== undefined && !prompt.linkTarget) { this.generateAndStoreSummary(prompt.id, data.content).catch(() => {}); @@ -130,6 +239,57 @@ export class PromptService { return prompt; } + /** + * Append a ResourceRevision row for this prompt and update its + * currentRevisionId. Best-effort — failures are swallowed because the + * audit log isn't load-bearing (the resource row's inline content is + * the source of truth). + */ + private async recordPromptRevision(prompt: Prompt, semver: string, note: string | null): Promise { + if (this.revisionService === null) return; + const body: Record = { content: prompt.content, priority: prompt.priority }; + if (prompt.linkTarget !== null) body['linkTarget'] = prompt.linkTarget; + const { revision } = await this.revisionService.record({ + resourceType: 'prompt', + resourceId: prompt.id, + semver, + body, + ...(note !== null ? { note } : {}), + }); + await this.promptRepo.update(prompt.id, { currentRevisionId: revision.id }); + } + + /** + * Restore a prompt to a prior revision: writes the revision's body + * back as a NEW update (which produces a new patch-bumped revision), + * preserving the audit chain. Returns the updated prompt. + */ + async restoreRevisionForPrompt(promptId: string, revisionId: string, note?: string): Promise { + if (this.revisionService === null) { + throw new Error('Revision service not wired'); + } + const revision = await this.revisionService.getById(revisionId); + if (revision === null) throw new NotFoundError(`Revision not found: ${revisionId}`); + if (revision.resourceType !== 'prompt' || revision.resourceId !== promptId) { + throw Object.assign( + new Error('Revision does not belong to this prompt'), + { statusCode: 400 }, + ); + } + const body = (revision.body ?? {}) as Record; + const content = typeof body['content'] === 'string' ? body['content'] : undefined; + const priority = typeof body['priority'] === 'number' ? body['priority'] : undefined; + if (content === undefined) { + throw Object.assign(new Error('Revision has no content to restore'), { statusCode: 400 }); + } + return this.updatePrompt(promptId, { + content, + priority, + bump: 'patch', + note: note ?? `restored from revision ${revisionId}`, + }); + } + async regenerateSummary(id: string): Promise { const prompt = await this.getPrompt(id); if (!this.summaryService) { @@ -226,6 +386,11 @@ export class PromptService { const prompt = await this.promptRepo.create(createData); + // Record the initial revision so the approved prompt has a v0.1.0 history entry. + if (this.revisionService) { + this.recordPromptRevision(prompt, '0.1.0', `approved promptrequest ${requestId}`).catch(() => {}); + } + // Delete the request await this.promptRequestRepo.delete(requestId); @@ -324,3 +489,4 @@ export class PromptService { return results; } } + diff --git a/src/mcpd/src/services/resource-proposal.service.ts b/src/mcpd/src/services/resource-proposal.service.ts new file mode 100644 index 0000000..db658af --- /dev/null +++ b/src/mcpd/src/services/resource-proposal.service.ts @@ -0,0 +1,133 @@ +import type { PrismaClient, Prisma, ResourceProposal } from '@prisma/client'; + +import type { + IResourceProposalRepository, + CreateProposalInput, + ProposalListFilter, +} from '../repositories/resource-proposal.repository.js'; +import type { ResourceType } from '../repositories/resource-revision.repository.js'; +import { NotFoundError } from './mcp-server.service.js'; + +/** + * Per-resourceType handler invoked when a proposal is approved. The + * handler runs inside the approval transaction; it must apply the + * proposed body to the live resource (creating it if needed), record + * a ResourceRevision, and return the resulting revision id so the + * proposal row can link to it. + * + * Registered by the resource's own service at boot time: + * PromptService → setHandler('prompt', ...) + * SkillService → setHandler('skill', ...) // PR-3 + */ +export type ProposalApprovalHandler = ( + proposal: ResourceProposal, + tx: Prisma.TransactionClient, + approverUserId?: string, +) => Promise<{ resourceId: string; revisionId: string }>; + +export interface ProposeInput { + resourceType: ResourceType; + name: string; + body: Record; + projectId?: string; + agentId?: string; + createdBySession?: string; + createdByUserId?: string; +} + +export class ResourceProposalService { + private readonly handlers = new Map(); + + constructor( + private readonly repo: IResourceProposalRepository, + private readonly prisma: PrismaClient, + ) {} + + /** Registered by Prompt/Skill services at construction time. */ + setHandler(resourceType: ResourceType, handler: ProposalApprovalHandler): void { + this.handlers.set(resourceType, handler); + } + + async list(filter: ProposalListFilter): Promise { + return this.repo.list(filter); + } + + async getById(id: string): Promise { + const proposal = await this.repo.findById(id); + if (proposal === null) throw new NotFoundError(`Proposal not found: ${id}`); + return proposal; + } + + async findBySession(sessionId: string, projectId?: string): Promise { + return this.repo.findBySession(sessionId, projectId); + } + + async propose(input: ProposeInput): Promise { + const data: CreateProposalInput = { + resourceType: input.resourceType, + name: input.name, + body: input.body as Prisma.InputJsonValue, + }; + if (input.projectId !== undefined) data.projectId = input.projectId; + if (input.agentId !== undefined) data.agentId = input.agentId; + if (input.createdBySession !== undefined) data.createdBySession = input.createdBySession; + if (input.createdByUserId !== undefined) data.createdByUserId = input.createdByUserId; + return this.repo.create(data); + } + + async updateBody(id: string, body: Record): Promise { + await this.getById(id); // 404 if missing + return this.repo.updateBody(id, body as Prisma.InputJsonValue); + } + + /** + * Approve the proposal: dispatch to the type-specific handler inside + * a transaction, then mark the proposal `approved` and link the + * resulting revision id. + */ + async approve(id: string, approverUserId?: string): Promise { + return this.prisma.$transaction(async (tx) => { + const proposal = await tx.resourceProposal.findUnique({ where: { id } }); + if (proposal === null) throw new NotFoundError(`Proposal not found: ${id}`); + if (proposal.status !== 'pending') { + throw Object.assign( + new Error(`Proposal is ${proposal.status}, not pending`), + { statusCode: 409 }, + ); + } + const handler = this.handlers.get(proposal.resourceType as ResourceType); + if (handler === undefined) { + throw Object.assign( + new Error(`No approval handler registered for resource type: ${proposal.resourceType}`), + { statusCode: 500 }, + ); + } + const { revisionId } = await handler(proposal, tx, approverUserId); + return tx.resourceProposal.update({ + where: { id }, + data: { + status: 'approved', + approvedRevisionId: revisionId, + version: { increment: 1 }, + ...(approverUserId !== undefined ? {} : {}), + }, + }); + }); + } + + async reject(id: string, reviewerNote: string, _reviewerUserId?: string): Promise { + const proposal = await this.getById(id); + if (proposal.status !== 'pending') { + throw Object.assign( + new Error(`Proposal is ${proposal.status}, not pending`), + { statusCode: 409 }, + ); + } + return this.repo.updateStatus(id, { status: 'rejected', reviewerNote }); + } + + async delete(id: string): Promise { + await this.getById(id); + await this.repo.delete(id); + } +} diff --git a/src/mcpd/src/services/resource-revision.service.ts b/src/mcpd/src/services/resource-revision.service.ts new file mode 100644 index 0000000..c9ce5e2 --- /dev/null +++ b/src/mcpd/src/services/resource-revision.service.ts @@ -0,0 +1,95 @@ +import crypto from 'node:crypto'; +import type { Prisma, ResourceRevision } from '@prisma/client'; + +import type { + IResourceRevisionRepository, + ResourceType, +} from '../repositories/resource-revision.repository.js'; + +export interface RecordRevisionInput { + resourceType: ResourceType; + resourceId: string; + /** New semver — caller computes via bumpSemver / explicit override. */ + semver: string; + /** + * Snapshot of the resource body at this revision. Shape is + * resource-specific — for Prompt: `{ content, priority, linkTarget }`; + * for Skill: `{ content, files, metadata, priority, description }`. + * Stored as-is in `body` (jsonb) and used as the diff/restore source + * by the revisions API. + */ + body: Record; + authorUserId?: string; + authorSessionId?: string; + note?: string; +} + +export class ResourceRevisionService { + constructor(private readonly repo: IResourceRevisionRepository) {} + + /** + * sha256 of the canonicalised body. Stable across key reorderings so a + * resource that's saved twice with the same logical content produces + * the same hash on both revisions — useful for sync-side dedup. + */ + static hash(body: unknown): string { + return 'sha256:' + crypto.createHash('sha256').update(canonicalJson(body)).digest('hex'); + } + + async record( + input: RecordRevisionInput, + tx?: Prisma.TransactionClient, + ): Promise<{ revision: ResourceRevision; contentHash: string }> { + const contentHash = ResourceRevisionService.hash(input.body); + const revision = await this.repo.create( + { + resourceType: input.resourceType, + resourceId: input.resourceId, + semver: input.semver, + contentHash, + body: input.body as Prisma.InputJsonValue, + ...(input.authorUserId !== undefined ? { authorUserId: input.authorUserId } : {}), + ...(input.authorSessionId !== undefined ? { authorSessionId: input.authorSessionId } : {}), + ...(input.note !== undefined ? { note: input.note } : {}), + }, + tx, + ); + return { revision, contentHash }; + } + + async getById(id: string): Promise { + return this.repo.findById(id); + } + + async listHistory( + resourceType: ResourceType, + resourceId: string, + limit?: number, + ): Promise { + return this.repo.findHistory(resourceType, resourceId, limit); + } + + async findBySemver( + resourceType: ResourceType, + resourceId: string, + semver: string, + ): Promise { + return this.repo.findBySemver(resourceType, resourceId, semver); + } +} + +/** + * Canonical JSON: keys sorted at every object level. Used by `hash` so + * `{a:1,b:2}` and `{b:2,a:1}` produce the same digest. + */ +function canonicalJson(v: unknown): string { + if (v === null || v === undefined || typeof v !== 'object') { + return JSON.stringify(v ?? null); + } + if (Array.isArray(v)) { + return '[' + v.map(canonicalJson).join(',') + ']'; + } + const obj = v as Record; + const keys = Object.keys(obj).sort(); + return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}'; +} diff --git a/src/mcpd/src/utils/semver.ts b/src/mcpd/src/utils/semver.ts new file mode 100644 index 0000000..b89f944 --- /dev/null +++ b/src/mcpd/src/utils/semver.ts @@ -0,0 +1,56 @@ +/** + * Tiny semver bumper for resource versions. mcpctl is the source of truth + * for prompts and skills; their versions are advisory rather than + * dependency-resolved, so we don't need a full semver library — just patch + * `0.1.0` → `0.1.1` on every save and let authors bump major/minor when + * something material changes. + * + * Anything that isn't a strict `MAJOR.MINOR.PATCH` (digits-only, three + * parts) is treated as invalid and replaced with `'0.1.0'`. We don't + * support pre-release / build-metadata suffixes for resources; if that + * ever becomes useful we can swap in `semver` from npm without changing + * call sites. + */ + +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + +export type BumpKind = 'major' | 'minor' | 'patch'; + +export function isValidSemver(s: string): boolean { + return SEMVER_RE.test(s); +} + +export function bumpSemver(current: string, kind: BumpKind): string { + const m = SEMVER_RE.exec(current); + if (m === null) { + // Caller passed something we can't parse — start over rather than + // silently corrupt. Prefer this to throwing because the call path + // (PromptService.update) would then propagate failure across the + // entire transaction including the body update. + return '0.1.0'; + } + const major = Number(m[1]); + const minor = Number(m[2]); + const patch = Number(m[3]); + switch (kind) { + case 'major': + return `${String(major + 1)}.0.0`; + case 'minor': + return `${String(major)}.${String(minor + 1)}.0`; + case 'patch': + return `${String(major)}.${String(minor)}.${String(patch + 1)}`; + } +} + +/** Compare a < b: returns -1, 0, +1 by major/minor/patch. Invalid → 0. */ +export function compareSemver(a: string, b: string): number { + const ma = SEMVER_RE.exec(a); + const mb = SEMVER_RE.exec(b); + if (ma === null || mb === null) return 0; + for (let i = 1; i <= 3; i++) { + const ai = Number(ma[i]); + const bi = Number(mb[i]); + if (ai !== bi) return ai < bi ? -1 : 1; + } + return 0; +} diff --git a/src/mcpd/src/validation/prompt.schema.ts b/src/mcpd/src/validation/prompt.schema.ts index dc1e56c..42787ad 100644 --- a/src/mcpd/src/validation/prompt.schema.ts +++ b/src/mcpd/src/validation/prompt.schema.ts @@ -16,9 +16,18 @@ export const CreatePromptSchema = z { message: 'A prompt may attach to a project XOR an agent, not both', path: ['agentId'] }, ); +const SEMVER_RE = /^\d+\.\d+\.\d+$/; + export const UpdatePromptSchema = z.object({ content: z.string().min(1).max(50000).optional(), priority: z.number().int().min(1).max(10).optional(), + // Semver controls (PR-2). At most one of `semver` and `bump` may be + // set; service layer rejects both. If neither is set, content changes + // auto-bump patch. + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), + bump: z.enum(['major', 'minor', 'patch']).optional(), + // Free-form note attached to the resulting ResourceRevision row. + note: z.string().max(500).optional(), // linkTarget intentionally excluded — links are immutable }); diff --git a/src/mcpd/tests/prompt-routes.test.ts b/src/mcpd/tests/prompt-routes.test.ts index 42a483c..9282327 100644 --- a/src/mcpd/tests/prompt-routes.test.ts +++ b/src/mcpd/tests/prompt-routes.test.ts @@ -17,10 +17,13 @@ function makePrompt(overrides: Partial = {}): Prompt { name: 'test-prompt', content: 'Hello world', projectId: null, + agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, + semver: '0.1.0', + currentRevisionId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), @@ -316,9 +319,11 @@ describe('Prompt routes', () => { payload: { content: 'new content', projectId: 'proj-evil' }, }); - // Should succeed but ignore projectId — UpdatePromptSchema strips it + // Should succeed but ignore projectId — UpdatePromptSchema strips it. + // PR-2: a content change auto-bumps the patch number, so the update + // call also carries the new semver. expect(res.statusCode).toBe(200); - expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content' }); + expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content', semver: '0.1.1' }); // projectId must NOT be in the update call const updateArg = vi.mocked(promptRepo.update).mock.calls[0]![1]; expect(updateArg).not.toHaveProperty('projectId'); diff --git a/src/mcpd/tests/services/prompt-service.test.ts b/src/mcpd/tests/services/prompt-service.test.ts index 4bb550d..bd1515e 100644 --- a/src/mcpd/tests/services/prompt-service.test.ts +++ b/src/mcpd/tests/services/prompt-service.test.ts @@ -11,10 +11,13 @@ function makePrompt(overrides: Partial = {}): Prompt { name: 'test-prompt', content: 'Hello world', projectId: null, + agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, + semver: '0.1.0', + currentRevisionId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), @@ -175,7 +178,9 @@ describe('PromptService', () => { it('should update prompt content', async () => { vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt()); await service.updatePrompt('prompt-1', { content: 'updated' }); - expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated' }); + // Auto-patch bump on content change (PR-2): updatePrompt now also + // emits the new semver in the same update call. + expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated', semver: '0.1.1' }); }); it('should throw for missing prompt', async () => { diff --git a/src/mcpd/tests/utils/semver.test.ts b/src/mcpd/tests/utils/semver.test.ts new file mode 100644 index 0000000..088cae8 --- /dev/null +++ b/src/mcpd/tests/utils/semver.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { bumpSemver, compareSemver, isValidSemver } from '../../src/utils/semver.js'; + +describe('bumpSemver', () => { + it('bumps patch', () => { + expect(bumpSemver('0.1.0', 'patch')).toBe('0.1.1'); + expect(bumpSemver('1.2.3', 'patch')).toBe('1.2.4'); + }); + + it('bumps minor and resets patch', () => { + expect(bumpSemver('0.1.5', 'minor')).toBe('0.2.0'); + expect(bumpSemver('1.2.3', 'minor')).toBe('1.3.0'); + }); + + it('bumps major and resets minor + patch', () => { + expect(bumpSemver('0.1.5', 'major')).toBe('1.0.0'); + expect(bumpSemver('1.2.3', 'major')).toBe('2.0.0'); + }); + + it('falls back to 0.1.0 on invalid input', () => { + expect(bumpSemver('not-a-semver', 'patch')).toBe('0.1.0'); + expect(bumpSemver('1.0', 'patch')).toBe('0.1.0'); + expect(bumpSemver('1.0.0-beta', 'patch')).toBe('0.1.0'); + expect(bumpSemver('', 'patch')).toBe('0.1.0'); + }); +}); + +describe('compareSemver', () => { + it('returns 0 for equal', () => { + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + }); + + it('returns -1 when a < b at any field', () => { + expect(compareSemver('1.2.3', '1.2.4')).toBe(-1); + expect(compareSemver('1.2.3', '1.3.0')).toBe(-1); + expect(compareSemver('1.2.3', '2.0.0')).toBe(-1); + }); + + it('returns +1 when a > b at any field', () => { + expect(compareSemver('1.2.4', '1.2.3')).toBe(1); + expect(compareSemver('1.3.0', '1.2.3')).toBe(1); + expect(compareSemver('2.0.0', '1.2.3')).toBe(1); + }); + + it('compares numerically (10 > 9, not lex)', () => { + expect(compareSemver('0.10.0', '0.9.0')).toBe(1); + expect(compareSemver('0.9.0', '0.10.0')).toBe(-1); + }); + + it('returns 0 for invalid input rather than throwing', () => { + expect(compareSemver('bad', '1.0.0')).toBe(0); + expect(compareSemver('1.0.0', 'bad')).toBe(0); + }); +}); + +describe('isValidSemver', () => { + it('accepts MAJOR.MINOR.PATCH digits', () => { + expect(isValidSemver('0.0.0')).toBe(true); + expect(isValidSemver('1.2.3')).toBe(true); + expect(isValidSemver('999.999.999')).toBe(true); + }); + + it('rejects everything else', () => { + expect(isValidSemver('1.2')).toBe(false); + expect(isValidSemver('1.2.3.4')).toBe(false); + expect(isValidSemver('v1.2.3')).toBe(false); + expect(isValidSemver('1.2.3-beta')).toBe(false); + expect(isValidSemver('')).toBe(false); + }); +});