diff --git a/package.json b/package.json index c3592ef..7fb0283 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "sharp": "^0.34.5", "slugify": "^1.6.9", "stripe": "^21.0.1", + "undici": "^8.10.0", "vue": "^3.5.40", "vue-router": "^5.2.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caade79..fc38ba3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: stripe: specifier: ^21.0.1 version: 21.0.1(@types/node@25.6.0) + undici: + specifier: ^8.10.0 + version: 8.10.0 vue: specifier: ^3.5.40 version: 3.5.40(typescript@5.9.3) @@ -6460,6 +6463,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -14251,6 +14258,8 @@ snapshots: undici@7.28.0: {} + undici@8.10.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 diff --git a/server/api/workspaces/[workspaceId]/projects/[projectId]/vocabulary.patch.ts b/server/api/workspaces/[workspaceId]/projects/[projectId]/vocabulary.patch.ts index 1cf66fb..de38318 100644 --- a/server/api/workspaces/[workspaceId]/projects/[projectId]/vocabulary.patch.ts +++ b/server/api/workspaces/[workspaceId]/projects/[projectId]/vocabulary.patch.ts @@ -79,15 +79,37 @@ export default defineEventHandler(async (event) => { base: CONTENT_BRANCH, }) - const mergeResult = await engine.mergeBranch(branchName) + let mergeResult: { merged: boolean, pullRequestUrl?: string | null } + try { + mergeResult = await engine.mergeBranch(branchName) + } + catch (err) { + // GitHub's merge endpoint answers a real conflict with 409 and the + // provider re-throws it (only "already merged" is absorbed) — so the + // concurrent-writer conflict this loop exists for used to arrive as + // an exception, escape the loop, and reach the UI as an unhandled + // 500 (staging, 2026-08-13 14:04Z). Treat it as the retryable + // conflict it is. + const status = err as { status?: number, statusCode?: number } + if (status.status === 409 || status.statusCode === 409) { + await git.deleteBranch(branchName).catch(() => { /* best-effort */ }) + continue + } + throw err + } - if (!mergeResult.merged) { + if (!mergeResult.merged && !mergeResult.pullRequestUrl) { // A concurrent write landed first and this one conflicts. Drop the // branch so it doesn't accumulate, then retry from fresh state. await git.deleteBranch(branchName).catch(() => { /* best-effort */ }) continue } + // Merged — or landed on `contentrain` with a PR fallback toward a + // protected main. Either way the vocabulary IS on `contentrain` + // (which is exactly what the verification below reads), so a PR + // fallback must not loop back into another identical write. + invalidateBrainCache(projectId) // The merge can succeed and still lose the term, when the other writer diff --git a/server/utils/mcp-cloud-proxy.ts b/server/utils/mcp-cloud-proxy.ts index 04b0eb7..cd9b24d 100644 --- a/server/utils/mcp-cloud-proxy.ts +++ b/server/utils/mcp-cloud-proxy.ts @@ -13,6 +13,7 @@ */ import type { H3Event } from 'h3' import { getHeader, getProxyRequestHeaders, proxyRequest, readRawBody, setResponseHeader, setResponseStatus } from 'h3' +import { Agent } from 'undici' import { MEDIA_TOOL_NAMES, WRITE_TOOL_NAMES } from '~~/server/utils/mcp-tool-classes' import { errorMessage } from '~~/server/utils/content-strings' import { invalidateBrainCache } from '~~/server/utils/brain-cache' @@ -273,7 +274,30 @@ export async function runMcpCloudProxy( const fetchOptions = { method: event.method, body: rawBody } if (!shouldInvalidateBrain) { - return await proxyRequest(event, target, { headers: proxyHeaders, fetchOptions }) + // Streaming branch: an MCP session's GET is a long-lived SSE stream + // that legitimately idles far past undici's 300s default bodyTimeout — + // nothing pings it (neither the SDK transport nor the loopback), so + // every quiet session died with an unhandled UND_ERR_BODY_TIMEOUT + // (staging, 3× on 2026-08-13). The dispatcher disables the body + // timeout for this hop only; global fetches keep their safety net. + try { + return await proxyRequest(event, target, { + headers: proxyHeaders, + fetchOptions: { ...fetchOptions, dispatcher: streamingDispatcher } as RequestInit, + }) + } + catch (err) { + // Once headers are flushed the response is unsalvageable — a broken + // pump (client hung up, upstream closed mid-stream) must end the + // socket quietly. Re-raising would hand a headers-sent response to + // Nitro's prod error handler, which throws ERR_HTTP_HEADERS_SENT + // into unhandledRejection. + if (event.node.res.headersSent) { + event.node.res.end() + return + } + throw err + } } // Write call — this one is buffered instead of streamed. @@ -324,6 +348,13 @@ export async function runMcpCloudProxy( return payload } +/** + * Dispatcher for the streaming proxy hop only: no body timeout (SSE idles + * indefinitely), headers timeout left at undici's default — the loopback + * answers headers immediately or not at all. + */ +const streamingDispatcher = new Agent({ bodyTimeout: 0 }) + /** Response headers that describe the upstream framing, not the payload. */ const HOP_BY_HOP_HEADERS = new Set([ 'connection', diff --git a/tests/integration/vocabulary-route.integration.test.ts b/tests/integration/vocabulary-route.integration.test.ts new file mode 100644 index 0000000..4416438 --- /dev/null +++ b/tests/integration/vocabulary-route.integration.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest' +import { withTestServer } from '../helpers/http' + +async function loadVocabularyPatchHandler() { + return (await import('../../server/api/workspaces/[workspaceId]/projects/[projectId]/vocabulary.patch')).default +} + +const VOCAB_JSON = JSON.stringify({ version: 1, terms: { cta: { en: 'Get started' } } }) + +function stubCommonGlobals(overrides: { mergeBranch: ReturnType }, git: Record) { + vi.stubGlobal('getRouterParam', vi.fn((_: unknown, key: string) => { + if (key === 'workspaceId') return 'workspace-1' + if (key === 'projectId') return 'project-1' + return undefined + })) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ + user: { id: 'editor-1', email: 'editor@example.com' }, + accessToken: 'token-1', + })) + vi.stubGlobal('resolveAgentPermissions', vi.fn().mockResolvedValue({ + workspaceRole: 'owner', + availableTools: ['save_content'], + specificModels: false, + allowedModels: [], + })) + vi.stubGlobal('resolveProjectContext', vi.fn().mockResolvedValue({ git, contentRoot: '' })) + vi.stubGlobal('createContentEngine', vi.fn().mockReturnValue({ + ensureContentBranch: vi.fn().mockResolvedValue(undefined), + mergeBranch: overrides.mergeBranch, + })) + vi.stubGlobal('generateBranchName', vi.fn(() => 'cr/content/vocabulary/1234567890-abcd')) + vi.stubGlobal('invalidateBrainCache', vi.fn()) +} + +describe('vocabulary route — merge-conflict resilience', () => { + it('retries when the GitHub merge throws a 409 conflict instead of surfacing a 500', async () => { + // The provider re-throws GitHub's 409 on a real merge conflict; the + // route used to let it escape the retry loop (unhandled 500 on + // staging, 2026-08-13 14:04Z). + const mergeBranch = vi.fn() + .mockRejectedValueOnce(Object.assign(new Error('Merge conflict'), { status: 409 })) + .mockResolvedValueOnce({ merged: true, sha: 'sha-2', pullRequestUrl: null }) + const git = { + readFile: vi.fn().mockResolvedValue(VOCAB_JSON), + applyPlan: vi.fn().mockResolvedValue({ sha: 'commit-1' }), + deleteBranch: vi.fn().mockResolvedValue(undefined), + } + stubCommonGlobals({ mergeBranch }, git) + + await withTestServer({ + routes: [ + { path: '/api/workspaces/workspace-1/projects/project-1/vocabulary', handler: await loadVocabularyPatchHandler() }, + ], + }, async ({ request }) => { + const response = await request('/api/workspaces/workspace-1/projects/project-1/vocabulary', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ terms: { cta: { en: 'Get started' } } }), + }) + + expect(response.status).toBe(200) + const payload = await response.json() + expect(payload.merged).toBe(true) + expect(payload.vocabulary.terms.cta.en).toBe('Get started') + // conflicted attempt cleaned its branch up, then a fresh write retried + expect(git.deleteBranch).toHaveBeenCalledTimes(1) + expect(git.applyPlan).toHaveBeenCalledTimes(2) + expect(mergeBranch).toHaveBeenCalledTimes(2) + }) + }) + + it('treats a PR fallback (protected main) as landed, not as a conflict to retry', async () => { + // finalize returns merged:false + pullRequestUrl when main is + // protected — but the vocabulary already reached `contentrain`. + // Retrying would re-write the same change up to MAX_ATTEMPTS and then + // report a bogus 409. + const mergeBranch = vi.fn().mockResolvedValue({ merged: false, pullRequestUrl: 'https://github.com/x/y/pull/1' }) + const git = { + readFile: vi.fn().mockResolvedValue(VOCAB_JSON), + applyPlan: vi.fn().mockResolvedValue({ sha: 'commit-1' }), + deleteBranch: vi.fn().mockResolvedValue(undefined), + } + stubCommonGlobals({ mergeBranch }, git) + + await withTestServer({ + routes: [ + { path: '/api/workspaces/workspace-1/projects/project-1/vocabulary', handler: await loadVocabularyPatchHandler() }, + ], + }, async ({ request }) => { + const response = await request('/api/workspaces/workspace-1/projects/project-1/vocabulary', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ terms: { cta: { en: 'Get started' } } }), + }) + + expect(response.status).toBe(200) + expect(git.applyPlan).toHaveBeenCalledTimes(1) + expect(git.deleteBranch).not.toHaveBeenCalled() + }) + }) + + it('propagates non-conflict merge failures unchanged', async () => { + const mergeBranch = vi.fn().mockRejectedValue(Object.assign(new Error('Bad credentials'), { status: 401 })) + const git = { + readFile: vi.fn().mockResolvedValue(VOCAB_JSON), + applyPlan: vi.fn().mockResolvedValue({ sha: 'commit-1' }), + deleteBranch: vi.fn().mockResolvedValue(undefined), + } + stubCommonGlobals({ mergeBranch }, git) + + await withTestServer({ + routes: [ + { path: '/api/workspaces/workspace-1/projects/project-1/vocabulary', handler: await loadVocabularyPatchHandler() }, + ], + }, async ({ request }) => { + const response = await request('/api/workspaces/workspace-1/projects/project-1/vocabulary', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ terms: { cta: { en: 'Get started' } } }), + }) + + // h3 maps the error's own status through; the point is that it is + // NOT swallowed into the conflict-retry path. + expect(response.status).toBe(401) + expect(git.applyPlan).toHaveBeenCalledTimes(1) + expect(git.deleteBranch).not.toHaveBeenCalled() + }) + }) +})