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:
Michal
2026-05-07 00:38:35 +01:00
parent fbe68fa693
commit 1ec286bb14
20 changed files with 1126 additions and 7 deletions

View File

@@ -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');

View File

@@ -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 () => {

View 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);
});
});