diff --git a/.mcp.json b/.mcp.json index ed95798b..5ba95d7f 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,7 +3,8 @@ "contentrain": { "command": "npx", "args": [ - "@contentrain/mcp" + "-y", + "@contentrain/mcp@1.5.0" ] }, "nuxt": { diff --git a/CLAUDE.md b/CLAUDE.md index aa08c1c8..a1b1592b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,13 +237,23 @@ itself. Every save / delete op composes: (`status: 'draft'`, `updated_by: 'contentrain-mcp'`) with Studio's `autoPublish` + existing-status preservation + per-user `updated_by` semantics. -4. `OverlayReader` + `buildContextChange` — wraps the plan changes - so `context.json` stats (entries per model, last-sync) reflect - the post-commit state, not the pre-change base branch. -5. `provider.applyPlan({ branch, changes, message, author, base: 'contentrain' })` +4. `provider.applyPlan({ branch, changes, message, author, base: 'contentrain' })` — atomic branch+commit via the GitHub Data API. `createBranch` is no longer called separately; `applyPlan` forks `base` when the - branch is missing. + branch is missing. **`context.json` is NOT part of `changes`** — + see the context.json invariant below. + +**`context.json` lifecycle (MCP 1.5.0 model)** — feature branches +**never** carry `context.json`. Committing it per-save caused merge +conflicts when parallel `cr/*` branches landed (each mutated the same +file from the same base). Instead it is regenerated deterministically +on the `contentrain` branch **after a merge**, in +`branch-ops.ts:mergeBranch` → `regenerateContextOnContentrain` +(`buildContextChange` over the merged tree + a dedicated +`applyPlan` commit onto `contentrain`, best-effort). The seed +`context.json` is still written once at `initProject` time. Brain cache +and external readers only ever read it from `contentrain`, so post-merge +regeneration is the single point it needs to be accurate. **Invariants to preserve** when touching this path: @@ -251,9 +261,9 @@ itself. Every save / delete op composes: fork from it via `applyPlan`'s default `base`. `config.repository .default_branch` (`main` / `master`) is informational — never the fork point. -- Post-change reads (for validation or context) go through - `OverlayReader(reader, pendingChanges)` — raw reader shows the - pre-change tree and will emit stale stats. +- Never add `context.json` to a feature-branch `applyPlan`. Only + `initProject` (seed) and `regenerateContextOnContentrain` (post-merge, + on `contentrain`) may write it. - Studio's `pinReaderToContentrain` wrapper defaults ref to `CONTENTRAIN_BRANCH` for every MCP read (MCP's helpers call `reader.readFile(path)` without a ref). @@ -262,7 +272,7 @@ itself. Every save / delete op composes: Medium: - Mobile shell: hamburger + slide-over (button exists, handler + drawer missing) -- Branch health: no 80+ branch threshold, no auto-delete merged cr/* branches +- Branch health: warn/block thresholds (default 50/80, config-driven via `branchWarnLimit`/`branchBlockLimit` since MCP 1.5.0) + merged `cr/*` auto-delete are implemented (`branch-health.ts`, `branch-cleanup.ts`). Remaining: surface health status in the UI - Brain cache: no GitHub webhook-triggered invalidation for external pushes (TTL-only, 10min) - MCP Cloud endpoint: `server/api/mcp/v1/[projectId]/[...].ts` awaits `@contentrain/mcp` `resolveProvider` callback (per-request provider resolution). Foundations (license entries, `mcp_cloud_keys` table, usage RPC) shipped in Faz S6 — route implementation pending. diff --git a/package.json b/package.json index 3c1a18f6..eb0b5053 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "dependencies": { "@anthropic-ai/sdk": "^0.80.0", "@aws-sdk/client-s3": "^3.1014.0", - "@contentrain/mcp": "1.4.0", - "@contentrain/query": "^5.1.2", - "@contentrain/types": "0.5.0", + "@contentrain/mcp": "1.5.0", + "@contentrain/query": "^6.0.0", + "@contentrain/types": "0.5.1", "@gitbeaker/rest": "^43.8.0", "@nuxt/eslint": "1.15.2", "@nuxt/image": "2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0eb2fe66..db7a66da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,14 +15,14 @@ importers: specifier: ^3.1014.0 version: 3.1014.0 '@contentrain/mcp': - specifier: 1.4.0 - version: 1.4.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1) + specifier: 1.5.0 + version: 1.5.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1) '@contentrain/query': - specifier: ^5.1.2 - version: 5.1.2 + specifier: ^6.0.0 + version: 6.0.0 '@contentrain/types': - specifier: 0.5.0 - version: 0.5.0 + specifier: 0.5.1 + version: 0.5.1 '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 @@ -565,8 +565,8 @@ packages: resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} engines: {node: '>=v18'} - '@contentrain/mcp@1.4.0': - resolution: {integrity: sha512-wIZVaquWzwxKnFtoIwaKThje7jMzQ37SDsBptxTVrTZxjef8LniYhYt/uMSQWy4pSDTGV6B9/jQrWnvoH40q5g==} + '@contentrain/mcp@1.5.0': + resolution: {integrity: sha512-PV+Pwb4o384Jb0joJP0eb2deLaZtWX8L346hBlapPVcGIuDUdzRhTXrI3jHV6b5wz+SiAF9Z0NUDI0mSdU95Sw==} hasBin: true peerDependencies: '@gitbeaker/rest': '>=43.0.0' @@ -577,15 +577,12 @@ packages: '@octokit/rest': optional: true - '@contentrain/query@5.1.2': - resolution: {integrity: sha512-t4ogva5SYJXsRJNDhCpkDURoipmDlDEB6tsOIi697fuOpVKi60c9sKchdDYUEd7zNqcgx0d2dZGKae8qLfVMng==} + '@contentrain/query@6.0.0': + resolution: {integrity: sha512-qiTB/K4VPRO18TA1TlcEK1oPD7jEkPm6qYESyqnZLnE/heuBai2AS8xO3M32dflS89Zi3JXNaoIMI3c6hSHoQg==} hasBin: true - '@contentrain/types@0.4.0': - resolution: {integrity: sha512-saZ1qgdOfDmoNN2/KwUV59HEvcpOPtwdgZtmEWFFTxtJ1Cze1FZQCVElQfocikseNh5dE/rO/f/oVi/H1W/usw==} - - '@contentrain/types@0.5.0': - resolution: {integrity: sha512-VWU2EERn3zVHYcDWyINqjiTM9p2kKl5H965qHRmWYlif+fWyzLUqsb7JzqOtvmGhsDSQfhT+8SLjs9duoLO6jg==} + '@contentrain/types@0.5.1': + resolution: {integrity: sha512-G8rlZFjtdSTVNVJVSsbO93pkmtVmxwXsRYSGmHK9q0Op672FEqSMHXNVCo+sF0+N/JYs+6kiW0GnsQOIV1v4Pg==} '@conventional-changelog/git-client@2.6.0': resolution: {integrity: sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==} @@ -5478,11 +5475,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - srvx@0.11.12: - resolution: {integrity: sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA==} - engines: {node: '>=20.16.0'} - hasBin: true - srvx@0.11.15: resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==} engines: {node: '>=20.16.0'} @@ -7015,9 +7007,9 @@ snapshots: conventional-commits-parser: 6.3.0 picocolors: 1.1.1 - '@contentrain/mcp@1.4.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1)': + '@contentrain/mcp@1.5.0(@gitbeaker/rest@43.8.0)(@octokit/rest@22.0.1)': dependencies: - '@contentrain/types': 0.5.0 + '@contentrain/types': 0.5.1 '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) simple-git: 3.33.0 typescript: 5.9.3 @@ -7032,13 +7024,11 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@contentrain/query@5.1.2': + '@contentrain/query@6.0.0': dependencies: - '@contentrain/types': 0.4.0 + '@contentrain/types': 0.5.1 - '@contentrain/types@0.4.0': {} - - '@contentrain/types@0.5.0': {} + '@contentrain/types@0.5.1': {} '@conventional-changelog/git-client@2.6.0(conventional-commits-parser@6.3.0)': dependencies: @@ -7539,7 +7529,7 @@ snapshots: pkg-types: 2.3.0 scule: 1.3.0 semver: 7.7.4 - srvx: 0.11.12 + srvx: 0.11.15 std-env: 3.10.0 tinyclip: 0.1.12 tinyexec: 1.1.1 @@ -12482,8 +12472,6 @@ snapshots: sprintf-js@1.0.3: {} - srvx@0.11.12: {} - srvx@0.11.15: {} stable-hash-x@0.2.0: {} diff --git a/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/cleanup.post.ts b/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/cleanup.post.ts index 93ff69dd..c58c4c04 100644 --- a/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/cleanup.post.ts +++ b/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/cleanup.post.ts @@ -14,8 +14,8 @@ export default defineEventHandler(async (event) => { const db = useDatabaseProvider() await db.requireWorkspaceRole(session.accessToken, session.user.id, workspaceId, ['owner', 'admin']) - const { git } = await resolveProjectContext(workspaceId, projectId) - const report = await cleanupMergedBranches(git, projectId) + const { git, contentRoot } = await resolveProjectContext(workspaceId, projectId) + const report = await cleanupMergedBranches(git, projectId, undefined, contentRoot) return report }) diff --git a/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/health.get.ts b/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/health.get.ts index 60b061db..57df275f 100644 --- a/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/health.get.ts +++ b/server/api/workspaces/[workspaceId]/projects/[projectId]/branches/health.get.ts @@ -15,6 +15,6 @@ export default defineEventHandler(async (event) => { const cached = await getHealthStatus(projectId) if (cached) return cached - const { git } = await resolveProjectContext(workspaceId, projectId) - return checkBranchHealth(git, projectId) + const { git, contentRoot } = await resolveProjectContext(workspaceId, projectId) + return checkBranchHealth(git, projectId, contentRoot) }) diff --git a/server/plugins/branch-cleanup.ts b/server/plugins/branch-cleanup.ts index 73ead067..fa60515e 100644 --- a/server/plugins/branch-cleanup.ts +++ b/server/plugins/branch-cleanup.ts @@ -27,7 +27,7 @@ export default defineNitroPlugin((nitroApp) => { async function runBranchCleanup() { const db = useDatabaseProvider() - const projects = await db.listAllActiveProjects('id, repo_full_name, workspace_id') + const projects = await db.listAllActiveProjects('id, repo_full_name, workspace_id, content_root') for (const project of projects) { try { @@ -44,7 +44,8 @@ async function runBranchCleanup() { repo, }) - const report = await cleanupMergedBranches(git, project.id as string) + const contentRoot = normalizeContentRoot(project.content_root as string) + const report = await cleanupMergedBranches(git, project.id as string, undefined, contentRoot) if (report.deleted.length > 0) { // eslint-disable-next-line no-console console.info(`[branch-cleanup] ${owner}/${repo}: deleted ${report.deleted.length} merged branches, ${report.remaining} remaining`) diff --git a/server/utils/branch-health.ts b/server/utils/branch-health.ts index d33072ca..a018f73f 100644 --- a/server/utils/branch-health.ts +++ b/server/utils/branch-health.ts @@ -1,16 +1,24 @@ /** * Branch health — monitors cr/* branch count and cleans up merged branches. * - * Per git-architecture.md §8.2: + * Per git-architecture.md §8.2 (default thresholds): * - 0–49 unmerged cr/*: OK — operations proceed * - 50–79 unmerged cr/*: WARNING — operations proceed, user alerted * - 80+ unmerged cr/*: BLOCKED — new write operations rejected * + * As of @contentrain/mcp 1.5.0 the warn/block thresholds are configurable + * via `config.json` (`branchWarnLimit` / `branchBlockLimit`). Studio honors + * those when present and falls back to 50/80 otherwise — see + * {@link resolveBranchLimits}. + * * Merged branches are auto-deleted after branchRetention days (default 30). * * Cache: Redis when available (multi-instance safe), in-memory Map fallback. */ +import type { ContentrainConfig } from '@contentrain/types' +import { CONTENTRAIN_BRANCH } from '@contentrain/types' import type { GitProvider } from '../providers/git' +import { resolveConfigPath } from './content-paths' import { getRedis } from './redis' // ── Types ──────────────────────────────────────────── @@ -29,6 +37,12 @@ export interface CleanupReport { status: BranchHealthStatus } +/** Resolved warn/block thresholds for the unmerged cr/* branch count. */ +export interface BranchLimits { + warn: number + block: number +} + // ── Constants ──────────────────────────────────────── const WARNING_THRESHOLD = 50 @@ -105,11 +119,45 @@ export async function clearHealthCache(): Promise { } } +// ── Threshold resolution (config-driven, MCP 1.5.0) ── + +const DEFAULT_LIMITS: BranchLimits = { warn: WARNING_THRESHOLD, block: BLOCKED_THRESHOLD } + +/** + * Resolve branch-health thresholds. MCP 1.5.0 exposes `branchWarnLimit` + * (default 50) and `branchBlockLimit` (default 80) on `config.json`; Studio + * honors them so operators can tune limits without a code change. Reads + * config from the `contentrain` content branch. + * + * Falls back to the 50/80 defaults when `contentRoot` is omitted, the config + * is unreadable, or the fields are absent/non-numeric. + */ +export async function resolveBranchLimits( + git: GitProvider, + contentRoot?: string, +): Promise { + if (contentRoot === undefined) return DEFAULT_LIMITS + try { + const raw = await git.readFile(resolveConfigPath({ contentRoot }), CONTENTRAIN_BRANCH) + const config = JSON.parse(raw) as ContentrainConfig + return { + warn: typeof config.branchWarnLimit === 'number' ? config.branchWarnLimit : WARNING_THRESHOLD, + block: typeof config.branchBlockLimit === 'number' ? config.branchBlockLimit : BLOCKED_THRESHOLD, + } + } + catch { + return DEFAULT_LIMITS + } +} + // ── Status calculation ─────────────────────────────── -export function calculateStatus(unmergedCount: number): BranchHealthStatus { - if (unmergedCount >= BLOCKED_THRESHOLD) return 'blocked' - if (unmergedCount >= WARNING_THRESHOLD) return 'warning' +export function calculateStatus( + unmergedCount: number, + limits: BranchLimits = DEFAULT_LIMITS, +): BranchHealthStatus { + if (unmergedCount >= limits.block) return 'blocked' + if (unmergedCount >= limits.warn) return 'warning' return 'ok' } @@ -118,6 +166,7 @@ export function calculateStatus(unmergedCount: number): BranchHealthStatus { export async function checkBranchHealth( git: GitProvider, projectId: string, + contentRoot?: string, ): Promise { const branches = await git.listBranches('cr/') let unmergedCount = 0 @@ -127,8 +176,9 @@ export async function checkBranchHealth( if (!merged) unmergedCount++ } + const limits = await resolveBranchLimits(git, contentRoot) const report: BranchHealthReport = { - status: calculateStatus(unmergedCount), + status: calculateStatus(unmergedCount, limits), unmergedCount, lastChecked: new Date().toISOString(), } @@ -142,6 +192,7 @@ export async function cleanupMergedBranches( git: GitProvider, projectId: string, retentionDays: number = DEFAULT_RETENTION_DAYS, + contentRoot?: string, ): Promise { const branches = await git.listBranches('cr/') const deleted: string[] = [] @@ -165,10 +216,11 @@ export async function cleanupMergedBranches( } const remaining = branches.length - deleted.length + const limits = await resolveBranchLimits(git, contentRoot) const report: CleanupReport = { deleted, remaining, - status: calculateStatus(remaining), + status: calculateStatus(remaining, limits), } // Update cache after cleanup diff --git a/server/utils/content-engine/branch-ops.ts b/server/utils/content-engine/branch-ops.ts index 60d89681..3340d034 100644 --- a/server/utils/content-engine/branch-ops.ts +++ b/server/utils/content-engine/branch-ops.ts @@ -1,5 +1,7 @@ +import { buildContextChange } from '@contentrain/mcp/core/context' import type { Branch, EngineInternalContext, MergeResult } from './types' -import { BRANCH_PREFIX, CONTENT_BRANCH } from './types' +import { BOT_AUTHOR, BRANCH_PREFIX, CONTENT_BRANCH } from './types' +import { pinReaderToContentrain } from './helpers' /** * Ensure the dedicated `contentrain` branch exists and is synced with main. @@ -89,6 +91,11 @@ export async function mergeBranch(ctx: EngineInternalContext, branch: string): P // Branch may have been auto-deleted } + // Regenerate context.json on contentrain now that the content has + // landed — feature branches no longer carry it (MCP 1.5.0 model), so + // it is rebuilt here from the merged tree before main is advanced. + await regenerateContextOnContentrain(ctx, branch) + // Step 2: advance contentrain -> main const defaultBranch = await ctx.git.getDefaultBranch() try { @@ -116,3 +123,59 @@ export async function mergeBranch(ctx: EngineInternalContext, branch: string): P export async function rejectBranch(ctx: EngineInternalContext, branch: string): Promise { await ctx.git.deleteBranch(branch) } + +/** + * Regenerate `context.json` deterministically on the `contentrain` branch + * after a feature branch lands (MCP 1.5.0 model). + * + * Feature branches no longer carry `context.json`, which removes the + * merge-conflict surface when parallel `cr/*` saves land. Instead the file + * is rebuilt here from the merged `contentrain` tree so its stats + * (model / entry counts) reflect post-merge reality. The brain cache and + * external readers only ever read `context.json` from `contentrain`, so + * this is the single point where it needs to be accurate. + * + * Best-effort: a failure (transient git error, or a concurrent + * regeneration losing the non-fast-forward ref update) is swallowed — the + * next merge regenerates it correctly. + */ +async function regenerateContextOnContentrain( + ctx: EngineInternalContext, + mergedBranch: string, +): Promise { + try { + const reader = pinReaderToContentrain(ctx.git) + const contextChange = await buildContextChange(reader, parseMergeOperation(mergedBranch), 'mcp-studio') + + // Skip an empty commit when the merged tree already carries an + // identical context.json. + try { + const current = await reader.readFile(contextChange.path) + if (current === contextChange.content) return + } + catch { /* no existing context.json — fall through and write it */ } + + await ctx.git.applyPlan({ + branch: CONTENT_BRANCH, + changes: [contextChange], + message: 'contentrain: regenerate context.json', + author: BOT_AUTHOR, + base: CONTENT_BRANCH, + }) + } + catch { + // Best-effort: context.json self-heals on the next merge. + } +} + +/** + * Derive the `context.json` lastOperation from a merged `cr/*` branch name. + * Format: `cr/{scope}/{target}[/{locale}]/{timestamp}-{suffix}`. + */ +function parseMergeOperation(branch: string): { tool: string, model: string, locale?: string } { + const parts = branch.split('/') + // cr / scope / target / [locale] / timestamp-suffix + const model = parts[2] ?? '' + const locale = parts.length >= 5 ? parts[3] : undefined + return { tool: 'merge', model, locale } +} diff --git a/server/utils/content-engine/delete-content.ts b/server/utils/content-engine/delete-content.ts index 77a5388b..6a30f983 100644 --- a/server/utils/content-engine/delete-content.ts +++ b/server/utils/content-engine/delete-content.ts @@ -1,6 +1,5 @@ import type { FileChange, ModelDefinition, RepoReader } from '@contentrain/types' import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' import { planContentDelete } from '@contentrain/mcp/core/ops' import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { EngineInternalContext, WriteResult } from './types' @@ -43,14 +42,9 @@ export async function deleteContent( const aggregatedChanges = [...changesByPath.values()] - const overlay = new OverlayReader(reader, aggregatedChanges) - const contextChange = await buildContextChange( - overlay, - { tool: 'delete_content', model: modelId, locale, entries: entryIds }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...aggregatedChanges, contextChange] + // context.json is regenerated on `contentrain` post-merge (MCP 1.5.0 + // model), not committed on the feature branch. + const allChanges: FileChange[] = [...aggregatedChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) diff --git a/server/utils/content-engine/helpers.ts b/server/utils/content-engine/helpers.ts index 5c160071..a044eae9 100644 --- a/server/utils/content-engine/helpers.ts +++ b/server/utils/content-engine/helpers.ts @@ -36,7 +36,7 @@ export async function createFeatureBranch( ): Promise<{ branchName: string, healthWarning?: string }> { if (ctx.projectId) { const cached = await getHealthStatus(ctx.projectId) - const health = cached ?? await checkBranchHealth(ctx.git, ctx.projectId) + const health = cached ?? await checkBranchHealth(ctx.git, ctx.projectId, ctx.pathCtx.contentRoot) if (health.status === 'blocked') { throw createError({ diff --git a/server/utils/content-engine/save-content.ts b/server/utils/content-engine/save-content.ts index f81909b0..40637832 100644 --- a/server/utils/content-engine/save-content.ts +++ b/server/utils/content-engine/save-content.ts @@ -1,8 +1,6 @@ import type { ContentrainConfig, FileChange, ModelDefinition, ValidationResult, Vocabulary } from '@contentrain/types' import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' import { planContentSave } from '@contentrain/mcp/core/ops' -import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { ValidationContext } from '../content-validation' import type { EngineInternalContext, WriteResult } from './types' import { BOT_AUTHOR, CONTENT_BRANCH } from './types' @@ -26,9 +24,8 @@ import { * - feature-branch lifecycle (`cr/*` name generation, health check) * - commit + diff bookkeeping for the `WriteResult` return shape * - * `OverlayReader` is wrapped around the pinned reader so the committed - * `context.json` reflects post-commit stats (see `.internal/refactor/ - * 02-studio-handoff.md` Faz S2.1 — Phase 10 tuzakları). + * `context.json` is not committed here — it is regenerated on + * `contentrain` post-merge (MCP 1.5.0 model; see `branch-ops.ts`). */ export async function saveContent( ctx: EngineInternalContext, @@ -164,19 +161,11 @@ export async function saveContent( userEmail, }) - const overlay = new OverlayReader(reader, patchedChanges) - const contextChange = await buildContextChange( - overlay, - { - tool: 'save_content', - model: modelId, - locale, - entries: modelDef.kind === 'collection' ? touchedIds : undefined, - }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...patchedChanges, contextChange] + // context.json is NOT committed on feature branches (MCP 1.5.0 model): + // it is regenerated deterministically on `contentrain` post-merge so + // parallel saves cannot conflict on it. See `regenerateContextOnContentrain` + // in `branch-ops.ts`. + const allChanges: FileChange[] = [...patchedChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) diff --git a/server/utils/content-engine/save-document.ts b/server/utils/content-engine/save-document.ts index 28d84c27..7f5e9481 100644 --- a/server/utils/content-engine/save-document.ts +++ b/server/utils/content-engine/save-document.ts @@ -1,8 +1,6 @@ import type { ContentrainConfig, FileChange, ModelDefinition, Vocabulary } from '@contentrain/types' import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH, validateSlug } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' import { planContentSave } from '@contentrain/mcp/core/ops' -import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { EngineInternalContext, WriteResult } from './types' import { BOT_AUTHOR, CONTENT_BRANCH } from './types' import { applyStudioMetaOverrides, pinReaderToContentrain, createFeatureBranch } from './helpers' @@ -12,9 +10,8 @@ import { applyStudioMetaOverrides, pinReaderToContentrain, createFeatureBranch } * * Delegates markdown serialization + path resolution to * `planContentSave` (document kind); Studio overrides meta with its - * own status + user-email logic and wires `OverlayReader` around the - * pending changes so the committed `context.json` reflects post-commit - * stats. + * own status + user-email logic. `context.json` is not touched here — + * it is regenerated on `contentrain` post-merge (MCP 1.5.0 model). */ export async function saveDocument( ctx: EngineInternalContext, @@ -104,14 +101,9 @@ export async function saveDocument( userEmail, }) - const overlay = new OverlayReader(reader, patchedChanges) - const contextChange = await buildContextChange( - overlay, - { tool: 'save_content', model: modelId, locale, entries: [safeSlug] }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...patchedChanges, contextChange] + // context.json is regenerated on `contentrain` post-merge, not committed + // here (MCP 1.5.0 model — see `branch-ops.ts`). + const allChanges: FileChange[] = [...patchedChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) diff --git a/server/utils/content-engine/save-model.ts b/server/utils/content-engine/save-model.ts index f818a161..0ee156ff 100644 --- a/server/utils/content-engine/save-model.ts +++ b/server/utils/content-engine/save-model.ts @@ -1,8 +1,6 @@ import type { ContentrainConfig, FileChange, ModelDefinition } from '@contentrain/types' import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' import { planModelSave } from '@contentrain/mcp/core/ops' -import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { EngineInternalContext, WriteResult } from './types' import { BOT_AUTHOR, CONTENT_BRANCH } from './types' import { pinReaderToContentrain, createFeatureBranch } from './helpers' @@ -13,8 +11,8 @@ import { pinReaderToContentrain, createFeatureBranch } from './helpers' * Schema validation (Studio-owned today, pending S3 unification with MCP) * runs first; if it passes, file assembly is delegated to * `planModelSave` — it writes `.contentrain/models/{id}.json` in - * canonical form. Studio adds the `context.json` change on top via - * `buildContextChange` wrapped in an `OverlayReader`. + * canonical form. `context.json` is regenerated on `contentrain` + * post-merge (MCP 1.5.0 model), not committed on the feature branch. */ export async function saveModel( ctx: EngineInternalContext, @@ -82,14 +80,7 @@ export async function saveModel( } } - const overlay = new OverlayReader(reader, plan.changes) - const contextChange = await buildContextChange( - overlay, - { tool: 'save_model', model: definition.id, locale: '' }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...plan.changes, contextChange] + const allChanges: FileChange[] = [...plan.changes] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'model', definition.id) diff --git a/server/utils/content-engine/update-status.ts b/server/utils/content-engine/update-status.ts index 478ed49a..694448c0 100644 --- a/server/utils/content-engine/update-status.ts +++ b/server/utils/content-engine/update-status.ts @@ -1,7 +1,5 @@ import type { EntryMeta, FileChange, ModelDefinition } from '@contentrain/types' import { canonicalStringify, CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH } from '@contentrain/types' -import { buildContextChange } from '@contentrain/mcp/core/context' -import { OverlayReader } from '@contentrain/mcp/core/overlay-reader' import type { EngineInternalContext, WriteResult } from './types' import { BOT_AUTHOR, CONTENT_BRANCH } from './types' import { pinReaderToContentrain, createFeatureBranch } from './helpers' @@ -35,7 +33,7 @@ export async function updateEntryStatus( for (const entryId of entryIds) { existingMeta[entryId] = { - ...(existingMeta[entryId] ?? {}), + ...existingMeta[entryId], status, updated_by: userEmail, } as EntryMeta @@ -43,15 +41,9 @@ export async function updateEntryStatus( const metaChange: FileChange = { path: metaPath, content: canonicalStringify(existingMeta) } - const overlay = new OverlayReader(reader, [metaChange]) - const contextChange = await buildContextChange( - overlay, - { tool: 'update_status', model: modelId, locale, entries: entryIds }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [metaChange, contextChange] - .toSorted((a, b) => a.path.localeCompare(b.path)) + // context.json is regenerated on `contentrain` post-merge (MCP 1.5.0 + // model), not committed on the feature branch. + const allChanges: FileChange[] = [metaChange] const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) @@ -135,14 +127,9 @@ export async function copyLocale( { path: targetMetaPath, content: metaContent }, ] - const overlay = new OverlayReader(reader, copyChanges) - const contextChange = await buildContextChange( - overlay, - { tool: 'copy_locale', model: modelId, locale: toLocale }, - 'mcp-studio', - ) - - const allChanges: FileChange[] = [...copyChanges, contextChange] + // context.json is regenerated on `contentrain` post-merge (MCP 1.5.0 + // model), not committed on the feature branch. + const allChanges: FileChange[] = [...copyChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) const { branchName } = await createFeatureBranch(ctx, 'content', modelId) diff --git a/tests/unit/content-engine.test.ts b/tests/unit/content-engine.test.ts index f4399e29..1ce737a1 100644 --- a/tests/unit/content-engine.test.ts +++ b/tests/unit/content-engine.test.ts @@ -102,6 +102,31 @@ describe('content engine', () => { }) }) + it('regenerates context.json on contentrain after a successful merge', async () => { + const applyPlan = vi.fn().mockResolvedValue(defaultCommit) + const git = createGitProvider({ + getDefaultBranch: vi.fn().mockResolvedValue('main'), + mergeBranch: vi.fn().mockResolvedValue({ merged: true, sha: 'merge-sha', pullRequestUrl: null }), + deleteBranch: vi.fn().mockResolvedValue(undefined), + applyPlan, + }) + const engine = createContentEngine({ git, contentRoot: '' }) + + await engine.mergeBranch('cr/content/faq/en/1234567890-abcd') + + // Feature branches no longer carry context.json (MCP 1.5.0 model); it + // is rebuilt on contentrain post-merge via a dedicated commit. + expect(applyPlan).toHaveBeenCalledWith( + expect.objectContaining({ + branch: 'contentrain', + base: 'contentrain', + changes: expect.arrayContaining([ + expect.objectContaining({ path: '.contentrain/context.json' }), + ]), + }), + ) + }) + it('falls back to PR creation when branch protection blocks step 2 merge', async () => { const git = createGitProvider({ getDefaultBranch: vi.fn().mockResolvedValue('main'), @@ -264,7 +289,7 @@ describe('content engine', () => { expect(result.validation.errors[0]?.message).toBe('Model does not support i18n') }) - it('saves model definitions and emits context change', async () => { + it('saves model definitions without committing context.json on the feature branch', async () => { const applyPlan = vi.fn().mockResolvedValue(defaultCommit) const git = createGitProvider({ readFile: vi.fn(async (path: string) => { @@ -299,10 +324,14 @@ describe('content engine', () => { base: 'contentrain', changes: expect.arrayContaining([ expect.objectContaining({ path: '.contentrain/models/authors.json' }), - expect.objectContaining({ path: '.contentrain/context.json' }), ]), }), ) + + // context.json is regenerated on contentrain post-merge (MCP 1.5.0 + // model), never committed on the feature branch. + const call = applyPlan.mock.calls[0]?.[0] as { changes: Array<{ path: string }> } + expect(call.changes.some(c => c.path.endsWith('context.json'))).toBe(false) }) it('initializes a project with config, models, content, and meta files', async () => {