Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"contentrain": {
"command": "npx",
"args": [
"@contentrain/mcp"
"-y",
"@contentrain/mcp@1.5.0"
]
},
"nuxt": {
Expand Down
28 changes: 19 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,23 +237,33 @@ 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:

- Content SSOT is the `contentrain` branch. Feature branches always
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).
Expand All @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 18 additions & 30 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
5 changes: 3 additions & 2 deletions server/plugins/branch-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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`)
Expand Down
64 changes: 58 additions & 6 deletions server/utils/branch-health.ts
Original file line number Diff line number Diff line change
@@ -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 ────────────────────────────────────────────
Expand All @@ -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
Expand Down Expand Up @@ -105,11 +119,45 @@ export async function clearHealthCache(): Promise<void> {
}
}

// ── 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<BranchLimits> {
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'
}

Expand All @@ -118,6 +166,7 @@ export function calculateStatus(unmergedCount: number): BranchHealthStatus {
export async function checkBranchHealth(
git: GitProvider,
projectId: string,
contentRoot?: string,
): Promise<BranchHealthReport> {
const branches = await git.listBranches('cr/')
let unmergedCount = 0
Expand All @@ -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(),
}
Expand All @@ -142,6 +192,7 @@ export async function cleanupMergedBranches(
git: GitProvider,
projectId: string,
retentionDays: number = DEFAULT_RETENTION_DAYS,
contentRoot?: string,
): Promise<CleanupReport> {
const branches = await git.listBranches('cr/')
const deleted: string[] = []
Expand All @@ -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
Expand Down
Loading
Loading