feat(mcpd): ResourceRevision + ResourceProposal services + Prompt revision integration
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=<id|live>
(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) <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,10 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
||||
.description('Edit a resource in your default editor (server, project)')
|
||||
.argument('<resource>', 'Resource type (server, project)')
|
||||
.argument('<name-or-id>', 'Resource name or ID')
|
||||
.action(async (resourceArg: string, nameOrId: string) => {
|
||||
.option('--bump <kind>', 'Bump prompt semver after edit: major | minor | patch')
|
||||
.option('--semver <version>', 'Set prompt semver explicitly (X.Y.Z)')
|
||||
.option('--note <message>', '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<string, unknown>;
|
||||
// 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 {
|
||||
|
||||
@@ -21,6 +21,14 @@ export const RESOURCE_ALIASES: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface PromptUpdateInput {
|
||||
priority?: number;
|
||||
summary?: string;
|
||||
chapters?: string[];
|
||||
semver?: string;
|
||||
currentRevisionId?: string | null;
|
||||
}
|
||||
|
||||
export interface IPromptRepository {
|
||||
|
||||
138
src/mcpd/src/repositories/resource-proposal.repository.ts
Normal file
138
src/mcpd/src/repositories/resource-proposal.repository.ts
Normal file
@@ -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<ResourceProposal[]>;
|
||||
findById(id: string): Promise<ResourceProposal | null>;
|
||||
findByName(resourceType: ResourceType, name: string, scope: { projectId: string | null; agentId: string | null }): Promise<ResourceProposal | null>;
|
||||
findBySession(sessionId: string, projectId?: string): Promise<ResourceProposal[]>;
|
||||
create(data: CreateProposalInput, tx?: Prisma.TransactionClient): Promise<ResourceProposal>;
|
||||
updateBody(id: string, body: Prisma.InputJsonValue): Promise<ResourceProposal>;
|
||||
updateStatus(id: string, data: UpdateProposalStatusInput, tx?: Prisma.TransactionClient): Promise<ResourceProposal>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class ResourceProposalRepository implements IResourceProposalRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async list(filter: ProposalListFilter): Promise<ResourceProposal[]> {
|
||||
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<ResourceProposal | null> {
|
||||
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<ResourceProposal | null> {
|
||||
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<ResourceProposal[]> {
|
||||
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<ResourceProposal> {
|
||||
const client = tx ?? this.prisma;
|
||||
return client.resourceProposal.create({ data });
|
||||
}
|
||||
|
||||
async updateBody(id: string, body: Prisma.InputJsonValue): Promise<ResourceProposal> {
|
||||
return this.prisma.resourceProposal.update({
|
||||
where: { id },
|
||||
data: { body, version: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
data: UpdateProposalStatusInput,
|
||||
tx?: Prisma.TransactionClient,
|
||||
): Promise<ResourceProposal> {
|
||||
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<void> {
|
||||
await this.prisma.resourceProposal.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
79
src/mcpd/src/repositories/resource-revision.repository.ts
Normal file
79
src/mcpd/src/repositories/resource-revision.repository.ts
Normal file
@@ -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<ResourceRevision>;
|
||||
findById(id: string): Promise<ResourceRevision | null>;
|
||||
findLatest(resourceType: ResourceType, resourceId: string): Promise<ResourceRevision | null>;
|
||||
findHistory(resourceType: ResourceType, resourceId: string, limit?: number): Promise<ResourceRevision[]>;
|
||||
findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise<ResourceRevision | null>;
|
||||
findByContentHash(contentHash: string): Promise<ResourceRevision[]>;
|
||||
}
|
||||
|
||||
export class ResourceRevisionRepository implements IResourceRevisionRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async create(data: CreateRevisionInput, tx?: Prisma.TransactionClient): Promise<ResourceRevision> {
|
||||
const client = tx ?? this.prisma;
|
||||
return client.resourceRevision.create({ data });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ResourceRevision | null> {
|
||||
return this.prisma.resourceRevision.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findLatest(resourceType: ResourceType, resourceId: string): Promise<ResourceRevision | null> {
|
||||
return this.prisma.resourceRevision.findFirst({
|
||||
where: { resourceType, resourceId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findHistory(resourceType: ResourceType, resourceId: string, limit = 100): Promise<ResourceRevision[]> {
|
||||
return this.prisma.resourceRevision.findMany({
|
||||
where: { resourceType, resourceId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise<ResourceRevision | null> {
|
||||
// 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<ResourceRevision[]> {
|
||||
return this.prisma.resourceRevision.findMany({
|
||||
where: { contentHash },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
157
src/mcpd/src/routes/proposals.ts
Normal file
157
src/mcpd/src/routes/proposals.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
createdBySession?: string;
|
||||
createdByUserId?: string;
|
||||
} = {
|
||||
resourceType: resourceType as ResourceType,
|
||||
name,
|
||||
body: proposalBody as Record<string, unknown>,
|
||||
};
|
||||
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<string, unknown> } }>(
|
||||
'/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);
|
||||
});
|
||||
}
|
||||
123
src/mcpd/src/routes/revisions.ts
Normal file
123
src/mcpd/src/routes/revisions.ts
Normal file
@@ -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<string, unknown>)['content'];
|
||||
if (typeof v === 'string') return v;
|
||||
}
|
||||
return JSON.stringify(body, null, 2);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<Prompt> {
|
||||
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<void> {
|
||||
if (this.revisionService === null) return;
|
||||
const body: Record<string, unknown> = { 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<Prompt> {
|
||||
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<string, unknown>;
|
||||
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<Prompt> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
133
src/mcpd/src/services/resource-proposal.service.ts
Normal file
133
src/mcpd/src/services/resource-proposal.service.ts
Normal file
@@ -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<string, unknown>;
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
createdBySession?: string;
|
||||
createdByUserId?: string;
|
||||
}
|
||||
|
||||
export class ResourceProposalService {
|
||||
private readonly handlers = new Map<ResourceType, ProposalApprovalHandler>();
|
||||
|
||||
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<ResourceProposal[]> {
|
||||
return this.repo.list(filter);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<ResourceProposal> {
|
||||
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<ResourceProposal[]> {
|
||||
return this.repo.findBySession(sessionId, projectId);
|
||||
}
|
||||
|
||||
async propose(input: ProposeInput): Promise<ResourceProposal> {
|
||||
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<string, unknown>): Promise<ResourceProposal> {
|
||||
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<ResourceProposal> {
|
||||
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<ResourceProposal> {
|
||||
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<void> {
|
||||
await this.getById(id);
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
}
|
||||
95
src/mcpd/src/services/resource-revision.service.ts
Normal file
95
src/mcpd/src/services/resource-revision.service.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<ResourceRevision | null> {
|
||||
return this.repo.findById(id);
|
||||
}
|
||||
|
||||
async listHistory(
|
||||
resourceType: ResourceType,
|
||||
resourceId: string,
|
||||
limit?: number,
|
||||
): Promise<ResourceRevision[]> {
|
||||
return this.repo.findHistory(resourceType, resourceId, limit);
|
||||
}
|
||||
|
||||
async findBySemver(
|
||||
resourceType: ResourceType,
|
||||
resourceId: string,
|
||||
semver: string,
|
||||
): Promise<ResourceRevision | null> {
|
||||
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<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}';
|
||||
}
|
||||
56
src/mcpd/src/utils/semver.ts
Normal file
56
src/mcpd/src/utils/semver.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -17,10 +17,13 @@ function makePrompt(overrides: Partial<Prompt> = {}): 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');
|
||||
|
||||
@@ -11,10 +11,13 @@ function makePrompt(overrides: Partial<Prompt> = {}): 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 () => {
|
||||
|
||||
70
src/mcpd/tests/utils/semver.test.ts
Normal file
70
src/mcpd/tests/utils/semver.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user