fix(settings): share MCP mutation queues across editors
This commit is contained in:
parent
b5391f7305
commit
95fbd06dc3
22
src/app/integrations/mcp/mutations.ts
Normal file
22
src/app/integrations/mcp/mutations.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { MCPConnectionID } from './types'
|
||||
|
||||
// Domain-owned: pending operations survive disposal of a Settings editor.
|
||||
const pendingByConnection = new Map<MCPConnectionID, Promise<unknown>>()
|
||||
|
||||
export function enqueueMCPConnectionMutation<T>(
|
||||
id: MCPConnectionID,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const previous = pendingByConnection.get(id) ?? Promise.resolve()
|
||||
const pending = previous.then(operation, operation)
|
||||
const settled = pending.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
pendingByConnection.set(id, settled)
|
||||
void settled.then(() => {
|
||||
if (pendingByConnection.get(id) === settled) pendingByConnection.delete(id)
|
||||
return undefined
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
|
@ -12,6 +12,9 @@ import {
|
|||
} from '@/app/integrations/mcp'
|
||||
import type { CredentialStatus } from '@/app/settings/credentials/types'
|
||||
|
||||
import { enqueueMCPConnectionMutation } from '../mutations'
|
||||
import type { MCPConnectionID } from '../types'
|
||||
|
||||
const connectionServices = {
|
||||
status: mcpConnectionCredentialStatus,
|
||||
save: saveMCPConnectionDraft,
|
||||
|
|
@ -27,18 +30,23 @@ export function useMCPConnectionSettings(
|
|||
const draft = ref<MCPConnectionDraft>(createMCPConnectionDraft())
|
||||
const tokenStatus = ref<CredentialStatus>('missing')
|
||||
const error = ref('')
|
||||
|
||||
let version = 0
|
||||
let disposed = false
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
disposed = true
|
||||
version++
|
||||
})
|
||||
|
||||
const savedConnection = computed(() =>
|
||||
mcpConnectionSettings.value.connections.find((connection) => connection.id === draft.value.id)
|
||||
)
|
||||
|
||||
function current(request: number): boolean {
|
||||
return !disposed && request === version
|
||||
}
|
||||
|
||||
function startAdd(): void {
|
||||
version++
|
||||
draft.value = createMCPConnectionDraft()
|
||||
|
|
@ -46,36 +54,39 @@ export function useMCPConnectionSettings(
|
|||
tokenStatus.value = 'missing'
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function startEdit(id: string): Promise<boolean> {
|
||||
const connection = mcpConnectionSettings.value.connections.find((item) => item.id === id)
|
||||
if (!connection) return false
|
||||
|
||||
const request = ++version
|
||||
draft.value = createMCPConnectionDraft(connection)
|
||||
tokenDraft.value = ''
|
||||
tokenStatus.value = 'missing'
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const status = await services.status(connection.id)
|
||||
if (!current(request)) return false
|
||||
|
||||
tokenStatus.value = status
|
||||
return true
|
||||
} catch (cause) {
|
||||
if (!current(request)) return false
|
||||
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
return true
|
||||
}
|
||||
}
|
||||
let mutationQueue: Promise<unknown> = Promise.resolve()
|
||||
function enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const pending = mutationQueue.then(operation, operation)
|
||||
mutationQueue = pending.catch(() => undefined)
|
||||
return pending
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
const request = ++version
|
||||
const target = { ...draft.value }
|
||||
const id: MCPConnectionID = draft.value.id ?? `mcp-${crypto.randomUUID()}`
|
||||
draft.value.id = id
|
||||
const target = { ...draft.value, id }
|
||||
const token = tokenDraft.value
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
if (
|
||||
target.enabled &&
|
||||
|
|
@ -85,13 +96,15 @@ export function useMCPConnectionSettings(
|
|||
) {
|
||||
throw new Error(automation.value.bearerTokenRequired)
|
||||
}
|
||||
await enqueue(async () => {
|
||||
|
||||
await enqueueMCPConnectionMutation(id, async () => {
|
||||
const connection = services.save({ ...target, enabled: false })
|
||||
if (target.authenticationType === 'none') await services.setCredential(connection.id, '')
|
||||
else if (token.trim()) await services.setCredential(connection.id, token)
|
||||
services.save({ ...target, id: connection.id })
|
||||
})
|
||||
if (!current(request)) return false
|
||||
|
||||
if (tokenDraft.value === token) tokenDraft.value = ''
|
||||
return true
|
||||
} catch (cause) {
|
||||
|
|
@ -99,20 +112,25 @@ export function useMCPConnectionSettings(
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCredential(): Promise<void> {
|
||||
const id = draft.value.id
|
||||
if (!id) return
|
||||
|
||||
const request = ++version
|
||||
const token = tokenDraft.value
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await enqueue(async () => {
|
||||
await enqueueMCPConnectionMutation(id, async () => {
|
||||
const connection = mcpConnectionSettings.value.connections.find((item) => item.id === id)
|
||||
if (!connection) throw new Error('Connection no longer exists')
|
||||
|
||||
services.save({ ...createMCPConnectionDraft(connection), enabled: false })
|
||||
await services.setCredential(id, '')
|
||||
})
|
||||
if (!current(request)) return
|
||||
|
||||
draft.value.enabled = false
|
||||
if (tokenDraft.value === token) tokenDraft.value = ''
|
||||
tokenStatus.value = 'missing'
|
||||
|
|
@ -120,25 +138,30 @@ export function useMCPConnectionSettings(
|
|||
if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(): Promise<boolean> {
|
||||
const id = draft.value.id
|
||||
if (!id) return false
|
||||
|
||||
const request = ++version
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await enqueue(() => services.remove(id))
|
||||
await enqueueMCPConnectionMutation(id, () => services.remove(id))
|
||||
return current(request)
|
||||
} catch (cause) {
|
||||
if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => draft.value.authenticationType,
|
||||
(type) => {
|
||||
if (type === 'none') tokenDraft.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
draft,
|
||||
tokenStatus,
|
||||
|
|
|
|||
|
|
@ -228,3 +228,55 @@ test('serializes overlapping credential replacements', async () => {
|
|||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('reopened editors serialize writes to the same connection while other connections proceed', async () => {
|
||||
const firstScope = effectScope()
|
||||
const secondScope = effectScope()
|
||||
let finish: () => void = () => undefined
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const writes: string[] = []
|
||||
const services = {
|
||||
status: async () => 'configured' as const,
|
||||
setCredential: async (_id: string, value: string) => {
|
||||
writes.push(value)
|
||||
if (value === 'first') await blocked
|
||||
},
|
||||
save: (draft: { id: `mcp-${string}` | null }) => ({
|
||||
...connection,
|
||||
id: draft.id ?? connection.id
|
||||
}),
|
||||
remove: async () => undefined
|
||||
}
|
||||
try {
|
||||
const first = firstScope.run(() =>
|
||||
useMCPConnectionSettings(ref('first'), ref({ bearerTokenRequired: 'Required' }), services)
|
||||
)
|
||||
const second = secondScope.run(() =>
|
||||
useMCPConnectionSettings(ref('second'), ref({ bearerTokenRequired: 'Required' }), services)
|
||||
)
|
||||
const other = secondScope.run(() =>
|
||||
useMCPConnectionSettings(ref('other'), ref({ bearerTokenRequired: 'Required' }), services)
|
||||
)
|
||||
if (!first || !second || !other) throw new Error('Missing scope')
|
||||
for (const editor of [first, second, other]) {
|
||||
editor.draft.value.id = connection.id
|
||||
editor.draft.value.authenticationType = 'bearer'
|
||||
}
|
||||
other.draft.value.id = 'mcp-other'
|
||||
const savingFirst = first.save()
|
||||
await Promise.resolve()
|
||||
firstScope.stop()
|
||||
const savingSecond = second.save()
|
||||
await other.save()
|
||||
expect(writes).toEqual(['first', 'other'])
|
||||
finish()
|
||||
await Promise.all([savingFirst, savingSecond])
|
||||
expect(writes).toEqual(['first', 'other', 'second'])
|
||||
} finally {
|
||||
finish()
|
||||
firstScope.stop()
|
||||
secondScope.stop()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue