Skip to content
Merged
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
81 changes: 81 additions & 0 deletions .changeset/phase-14b-serve-backend-watchers-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
"contentrain": minor
---

feat(cli): serve backend — meta watcher, watcher error broadcast, new routes, defensive Zod

Second pass on `contentrain serve` after Phase 13's auth + drift fixes.
Tight, surgical changes — no behaviour regressions, additive routes
and events the Serve UI can consume immediately.

### File watcher coverage

- **`.contentrain/meta/`** — the chokidar handler now recognises
`meta/<model>/<locale>.json` and `meta/<model>/<entry>/<locale>.json`
paths and broadcasts a `meta:changed` WebSocket event with `modelId`,
optional `entryId`, and `locale`. Matches the two real layouts
agents produce (per-model SEO metadata, per-entry SEO metadata).
Without this, editing a `.contentrain/meta/*` file left the Serve
UI rendering stale metadata until a full refresh.
- **Watcher errors surfaced** — `chokidar.on('error', …)` was
previously unhandled. Now broadcasts `file-watch:error` with
`message` + ISO `timestamp`. The UI can render a "watcher down,
live updates paused" banner instead of silently degrading (e.g.
hitting the OS inotify limit on Linux).

### New HTTP routes

- **`GET /api/describe-format`** — thin wrapper around the
`contentrain_describe_format` MCP tool. The Serve UI can render
this as a format-reference panel alongside the model inspector
(what the `contentrain describe-format` CLI command shows locally).
- **`GET /api/preview/merge?branch=cr/...`** — preview a merge
before approving it, with zero side effects:
- `alreadyMerged` — the feature branch is already in
`CONTENTRAIN_BRANCH`'s history (approve would be a no-op)
- `canFastForward` — `CONTENTRAIN_BRANCH` is an ancestor of the
feature branch (approve will FF cleanly)
- `conflicts` — best-effort list of conflicting paths from
`git merge-tree`. Empty array on clean merges; `null` when the
check can't run (older git, missing refs). Complements the
approve route, which continues to surface runtime conflicts by
throwing.
- `filesChanged`, `stat` — from the shared `branchDiff()` helper
so UI preview + actual approve see the same file list.

### Defensive Zod parity

- **`/api/normalize/plan/reject`** — previously validated nothing;
now parses an optional `{ reason? }` body through a new
`NormalizePlanRejectBodySchema`. Both empty-body and reason-only
requests still work (backwards compatible); malformed bodies
return a structured 400 instead of silently succeeding. Keeps the
entire serve write surface parsing through one `parseOrThrow()`
gate.

### Explicitly out of scope

- **`/api/doctor`** — the MCP surface has no `contentrain_doctor`
tool yet; only the CLI's 540-line command. Proper route requires
extracting doctor into a reusable `@contentrain/mcp` tool first,
which is its own phase (14c candidate). Rather than duplicate
CLI logic into serve, we defer.
- **`sdk:regenerated` WS event** — `contentrain generate` runs
outside serve's process, so the watcher can't observe it cleanly.
Needs a different mechanism (e.g. a sentinel file, or integrating
generate into serve's lifecycle). Defer until the design is
concrete.

### Verification

- `oxlint` across cli/src + cli/tests → 0 warnings on 209 files.
- `contentrain` typecheck — 0 errors.
- `contentrain` vitest → **137/137** (was 130 on `next-mcp`). The 7
new tests cover: `meta:changed` with and without `entryId`,
`file-watch:error` payload shape, `/api/describe-format` tool
invocation, `/api/preview/merge` validation + happy path, and
the plan/reject route's body-validation + backwards compat.

### Tool surface

No MCP changes — this is pure serve-backend work on existing tools.
10 changes: 10 additions & 0 deletions packages/cli/src/serve/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ export const NormalizePlanApproveBodySchema = z.object({
models: z.array(z.string()).optional(),
})

/**
* Body for `/api/normalize/plan/reject`. Currently the route only
* deletes the plan file, but we validate the body shape anyway so
* any future caller that wants to record a rejection reason has a
* well-defined contract. Both an empty body and `{ reason? }` pass.
*/
export const NormalizePlanRejectBodySchema = z.object({
reason: z.string().max(500).optional(),
}).optional()

/** Query params for `/api/normalize/file-context`. */
export const FileContextQuerySchema = z.object({
file: z.string()
Expand Down
127 changes: 127 additions & 0 deletions packages/cli/src/serve/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
FileContextQuerySchema,
NormalizeApplyBodySchema,
NormalizePlanApproveBodySchema,
NormalizePlanRejectBodySchema,
parseOrThrow,
} from './schemas.js'

Expand Down Expand Up @@ -186,6 +187,18 @@ export async function createServeApp(options: ServeOptions) {
} else if (rel.startsWith('content/')) {
const parts = rel.replace('content/', '').split('/')
event = { type: 'content:changed', modelId: parts[1], locale: parts[2]?.replace('.json', '') }
} else if (rel.startsWith('meta/') && rel.endsWith('.json')) {
// `.contentrain/meta/<model>/<locale>.json` and
// `.contentrain/meta/<model>/<entryId>/<locale>.json` — SEO and
// model-level metadata edited independently of content. The
// Serve UI's model inspector and SEO panels consume this.
const parts = rel.replace('meta/', '').split('/')
const locale = parts[parts.length - 1]?.replace('.json', '')
const modelId = parts[0]
const entryId = parts.length === 3 ? parts[1] : undefined
event = entryId
? { type: 'meta:changed', modelId, entryId, locale }
: { type: 'meta:changed', modelId, locale }
} else {
return
}
Expand All @@ -194,6 +207,15 @@ export async function createServeApp(options: ServeOptions) {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(flush, 300)
})

// Surface watcher failures instead of silently degrading to a
// no-op. Without this the UI keeps rendering stale data after,
// e.g., hitting the OS inotify limit — the user has no way to
// know live updates stopped flowing.
watcher.on('error', (err) => {
const message = err instanceof Error ? err.message : String(err)
broadcast({ type: 'file-watch:error', message, timestamp: new Date().toISOString() })
})
}

// --- API Routes ---
Expand Down Expand Up @@ -249,6 +271,14 @@ export async function createServeApp(options: ServeOptions) {
return await callTool('contentrain_describe', { model: modelId, include_sample: true })
}))

// Format reference — thin wrapper around contentrain_describe_format.
// Serves a static spec of how Contentrain stores models, content,
// meta, and vocabulary. The UI renders this as a reference panel for
// humans who want to learn the file layout without reading docs.
router.add('/api/describe-format', defineEventHandler(async () => {
return await callTool('contentrain_describe_format')
}))

// Content list
router.add('/api/content/:modelId', defineEventHandler(async (event) => {
const modelId = getRouterParam(event, 'modelId')
Expand Down Expand Up @@ -344,6 +374,96 @@ export async function createServeApp(options: ServeOptions) {
}
}))

// Merge preview — answers "if I approve this branch right now, what
// would happen?" without running the merge. Three signals:
// - `alreadyMerged` — the feature branch is already in
// CONTENTRAIN_BRANCH's history (approve would be a no-op)
// - `canFastForward` — CONTENTRAIN_BRANCH is an ancestor of the
// feature branch (approve will fast-forward cleanly)
// - `conflicts` — best-effort list of conflicting paths detected
// via `git merge-tree`. Empty array when the check succeeds
// with no conflicts; `null` when the check couldn't run (older
// git, detached refs, etc.).
//
// Intentionally does NOT run the real merge — the approve route
// already surfaces runtime conflicts by throwing. This is a fast,
// side-effect-free signal for the UI to render a "preview" banner
// before the user commits to approve.
router.add('/api/preview/merge', defineEventHandler(async (event) => {
const query = getQuery(event)
const branchName = query['branch'] as string | undefined
if (!branchName || !branchName.startsWith('cr/')) {
throw createError({ statusCode: 400, message: 'Missing or invalid `branch` query (must start with "cr/")' })
}

const git = simpleGit(projectRoot)

// Confirm the branch exists locally.
const local = await git.branchLocal()
if (!local.all.includes(branchName)) {
throw createError({ statusCode: 404, message: `Branch "${branchName}" does not exist locally` })
}

// Fast-forward / already-merged checks.
let alreadyMerged = false
let canFastForward = false
try {
await git.raw(['merge-base', '--is-ancestor', branchName, CONTENTRAIN_BRANCH])
alreadyMerged = true
} catch { /* not merged yet */ }
if (!alreadyMerged) {
try {
await git.raw(['merge-base', '--is-ancestor', CONTENTRAIN_BRANCH, branchName])
canFastForward = true
} catch { /* would require a 3-way merge */ }
}

// Best-effort conflict scan via `git merge-tree`. The legacy 3-way
// form (`merge-tree <base> <ours> <theirs>`) is widely supported
// and prints conflict sections on stdout; silence = clean.
let conflicts: string[] | null = null
try {
const mergeBase = (await git.raw(['merge-base', CONTENTRAIN_BRANCH, branchName])).trim()
if (mergeBase) {
const out = await git.raw(['merge-tree', mergeBase, CONTENTRAIN_BRANCH, branchName])
// Conflict sections look like `changed in both\n base 100644 <sha> <path>...`
// — extract unique paths from the `<<<<<<<` / `>>>>>>>` surrounds.
const paths = new Set<string>()
const conflictBlockRegex = /^(?:changed in both|added in both|removed in (?:local|remote))\b[^\n]*\n((?:[ \t][^\n]*\n)+)/gmu
let match: RegExpExecArray | null
while ((match = conflictBlockRegex.exec(out)) !== null) {
const block = match[1] ?? ''
const pathMatch = /\b100644\s+[0-9a-f]{4,}\s+(\S+)/u.exec(block)
if (pathMatch?.[1]) paths.add(pathMatch[1])
}
conflicts = [...paths]
}
} catch { /* merge-tree unavailable or failed — leave as null */ }

// Diff summary against CONTENTRAIN_BRANCH (the same base the
// actual merge will use). Skip when already merged — the diff is
// empty and the MCP helper would error on a zero-commit range.
let stat = ''
let filesChanged = 0
if (!alreadyMerged) {
try {
const diff = await branchDiff(projectRoot, { branch: branchName })
stat = diff.stat
filesChanged = diff.filesChanged
} catch { /* leave zeroed */ }
}

return {
branch: branchName,
base: CONTENTRAIN_BRANCH,
alreadyMerged,
canFastForward,
conflicts,
filesChanged,
stat,
}
}))

// Branch approve — delegates to MCP's mergeBranch helper, which
// runs the worktree transaction + selective sync with dirty-file
// protection. `sync.skipped[]` surfaces files that the developer
Expand Down Expand Up @@ -564,6 +684,13 @@ export async function createServeApp(options: ServeOptions) {
if (event.method !== 'DELETE' && event.method !== 'POST') {
throw createError({ statusCode: 405, message: 'Method not allowed' })
}
// Validate the (optional) body so future callers that want to
// record a rejection reason have a well-defined contract, and so
// every write route here parses through the same Zod gate.
if (event.method === 'POST') {
const raw = await readBody(event).catch(() => undefined)
if (raw !== undefined) parseOrThrow(NormalizePlanRejectBodySchema, raw)
}
const planPath = join(crDir, 'normalize-plan.json')
if (existsSync(planPath)) {
await unlink(planPath)
Expand Down
Loading
Loading