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

@@ -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 {

View File

@@ -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',