mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
fix(skill): fallback to direct save for collaboration followers on sync failure or timeout
This commit is contained in:
parent
4e397a97a5
commit
e6e9b7ae94
@ -300,8 +300,8 @@ class SkillCollaborationManager {
|
||||
return this.docs.has(fileId)
|
||||
}
|
||||
|
||||
requestSync(fileId: string): void {
|
||||
this.emitSyncRequest(fileId)
|
||||
requestSync(fileId: string): boolean {
|
||||
return this.emitSyncRequest(fileId)
|
||||
}
|
||||
|
||||
emitCursorUpdate(fileId: string, cursor: { start: number, end: number } | null): void {
|
||||
@ -392,15 +392,17 @@ class SkillCollaborationManager {
|
||||
})
|
||||
}
|
||||
|
||||
private emitSyncRequest(fileId: string): void {
|
||||
private emitSyncRequest(fileId: string): boolean {
|
||||
if (!this.socket || !this.socket.connected)
|
||||
return
|
||||
return false
|
||||
|
||||
emitWithAuthGuard(this.socket, 'collaboration_event', {
|
||||
type: 'skill_sync_request',
|
||||
data: { file_id: fileId },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private emitSkillFileActive(fileId: string, active: boolean): void {
|
||||
|
||||
@ -3,15 +3,30 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { START_TAB_ID } from '../constants'
|
||||
import { useSkillSaveManager } from './skill-save-context'
|
||||
import { SkillSaveProvider } from './use-skill-save-manager'
|
||||
|
||||
const { mockMutateAsync, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockMutateAsync,
|
||||
mockToastSuccess,
|
||||
mockToastError,
|
||||
mockIsFileCollaborative,
|
||||
mockIsLeader,
|
||||
mockRequestSync,
|
||||
mockEmitFileSaved,
|
||||
mockOnAnyFileSaved,
|
||||
} = vi.hoisted(() => ({
|
||||
mockMutateAsync: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
mockIsFileCollaborative: vi.fn<(fileId: string) => boolean>(() => false),
|
||||
mockIsLeader: vi.fn<(fileId: string) => boolean>(() => true),
|
||||
mockRequestSync: vi.fn<(fileId: string) => boolean>(() => true),
|
||||
mockEmitFileSaved: vi.fn<(fileId: string, content: string, metadata?: Record<string, unknown>) => void>(),
|
||||
mockOnAnyFileSaved: vi.fn<(callback: (payload: { file_id: string, content?: string, metadata?: Record<string, unknown> }) => void) => () => void>(() => vi.fn()),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
@ -27,6 +42,16 @@ vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../collaboration/skills/skill-collaboration-manager', () => ({
|
||||
skillCollaborationManager: {
|
||||
isFileCollaborative: (fileId: string) => mockIsFileCollaborative(fileId),
|
||||
isLeader: (fileId: string) => mockIsLeader(fileId),
|
||||
requestSync: (fileId: string) => mockRequestSync(fileId),
|
||||
emitFileSaved: (fileId: string, content: string, metadata?: Record<string, unknown>) => mockEmitFileSaved(fileId, content, metadata),
|
||||
onAnyFileSaved: (callback: (payload: { file_id: string, content?: string, metadata?: Record<string, unknown> }) => void) => mockOnAnyFileSaved(callback),
|
||||
},
|
||||
}))
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -70,7 +95,20 @@ const getCachedPayload = (queryClient: QueryClient, appId: string, fileId: strin
|
||||
describe('useSkillSaveManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
mockIsFileCollaborative.mockReturnValue(false)
|
||||
mockIsLeader.mockReturnValue(true)
|
||||
mockRequestSync.mockReturnValue(true)
|
||||
mockOnAnyFileSaved.mockImplementation(() => vi.fn())
|
||||
|
||||
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: {
|
||||
...currentFeatures,
|
||||
enable_collaboration_mode: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw when used outside provider', () => {
|
||||
@ -125,6 +163,81 @@ describe('useSkillSaveManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: follower saves should fall back to direct persistence if delegated sync cannot complete.
|
||||
describe('Collaboration Fallback', () => {
|
||||
it('should persist directly when sync request cannot be sent for a follower', async () => {
|
||||
// Arrange
|
||||
const appId = 'app-1'
|
||||
const fileId = 'file-1'
|
||||
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: {
|
||||
...currentFeatures,
|
||||
enable_collaboration_mode: true,
|
||||
},
|
||||
})
|
||||
mockIsFileCollaborative.mockReturnValue(true)
|
||||
mockIsLeader.mockReturnValue(false)
|
||||
mockRequestSync.mockReturnValue(false)
|
||||
|
||||
const store = createWorkflowStore({})
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = createWrapper({ appId, store, queryClient })
|
||||
store.getState().setDraftContent(fileId, 'draft-content')
|
||||
const { result } = renderHook(() => useSkillSaveManager(), { wrapper })
|
||||
|
||||
// Act
|
||||
const response = await result.current.saveFile(fileId)
|
||||
|
||||
// Assert
|
||||
expect(response.saved).toBe(true)
|
||||
expect(mockRequestSync).toHaveBeenCalledWith(fileId)
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
appId,
|
||||
nodeId: fileId,
|
||||
payload: { content: 'draft-content' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should persist directly after delegated follower sync times out', async () => {
|
||||
// Arrange
|
||||
vi.useFakeTimers()
|
||||
|
||||
const appId = 'app-1'
|
||||
const fileId = 'file-1'
|
||||
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: {
|
||||
...currentFeatures,
|
||||
enable_collaboration_mode: true,
|
||||
},
|
||||
})
|
||||
mockIsFileCollaborative.mockReturnValue(true)
|
||||
mockIsLeader.mockReturnValue(false)
|
||||
mockRequestSync.mockReturnValue(true)
|
||||
|
||||
const store = createWorkflowStore({})
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = createWrapper({ appId, store, queryClient })
|
||||
store.getState().setDraftContent(fileId, 'draft-content')
|
||||
const { result } = renderHook(() => useSkillSaveManager(), { wrapper })
|
||||
|
||||
// Act
|
||||
const saveTask = result.current.saveFile(fileId)
|
||||
await vi.advanceTimersByTimeAsync(1600)
|
||||
const response = await saveTask
|
||||
|
||||
// Assert
|
||||
expect(response.saved).toBe(true)
|
||||
expect(mockRequestSync).toHaveBeenCalledWith(fileId)
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
appId,
|
||||
nodeId: fileId,
|
||||
payload: { content: 'draft-content' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: successful saves update cache and clear draft state.
|
||||
describe('Saving', () => {
|
||||
it('should save draft content, update cache, and clear draft content', async () => {
|
||||
|
||||
@ -48,6 +48,13 @@ type SkillSaveProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type CollaborativeSaveWaiter = {
|
||||
promise: Promise<boolean>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
const COLLABORATION_SYNC_TIMEOUT_MS = 1500
|
||||
|
||||
const normalizeMetadata = (
|
||||
rawMetadata: Record<string, unknown> | undefined,
|
||||
content: string,
|
||||
@ -167,6 +174,44 @@ export const SkillSaveProvider = ({
|
||||
patchFileContentCache(queryClient, queryKey, serialized)
|
||||
}, [appId, queryClient])
|
||||
|
||||
const createCollaborativeSaveWaiter = useCallback((fileId: string): CollaborativeSaveWaiter => {
|
||||
let settled = false
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const promise = new Promise<boolean>((resolve) => {
|
||||
const finish = (saved: boolean) => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
if (timeoutId !== null)
|
||||
clearTimeout(timeoutId)
|
||||
unsubscribe?.()
|
||||
resolve(saved)
|
||||
}
|
||||
|
||||
unsubscribe = skillCollaborationManager.onAnyFileSaved((payload) => {
|
||||
if (!payload || payload.file_id !== fileId)
|
||||
return
|
||||
finish(true)
|
||||
})
|
||||
|
||||
timeoutId = setTimeout(() => finish(false), COLLABORATION_SYNC_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
if (timeoutId !== null)
|
||||
clearTimeout(timeoutId)
|
||||
unsubscribe?.()
|
||||
},
|
||||
}
|
||||
}, [])
|
||||
|
||||
const performSave = useCallback(async (
|
||||
fileId: string,
|
||||
options?: SaveFileOptions,
|
||||
@ -174,9 +219,21 @@ export const SkillSaveProvider = ({
|
||||
if (!appId || !fileId || fileId === START_TAB_ID)
|
||||
return { saved: false }
|
||||
|
||||
if (isCollaborationEnabled && skillCollaborationManager.isFileCollaborative(fileId) && !skillCollaborationManager.isLeader(fileId)) {
|
||||
skillCollaborationManager.requestSync(fileId)
|
||||
return { saved: false }
|
||||
const isCollaborativeFollower = isCollaborationEnabled
|
||||
&& skillCollaborationManager.isFileCollaborative(fileId)
|
||||
&& !skillCollaborationManager.isLeader(fileId)
|
||||
|
||||
if (isCollaborativeFollower) {
|
||||
const delegatedSaveWaiter = createCollaborativeSaveWaiter(fileId)
|
||||
const didRequestSync = skillCollaborationManager.requestSync(fileId)
|
||||
if (didRequestSync) {
|
||||
const wasSavedByLeader = await delegatedSaveWaiter.promise
|
||||
if (wasSavedByLeader)
|
||||
return { saved: true }
|
||||
}
|
||||
else {
|
||||
delegatedSaveWaiter.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = buildSnapshot(fileId, options?.fallbackContent, options?.fallbackMetadata)
|
||||
@ -219,7 +276,7 @@ export const SkillSaveProvider = ({
|
||||
catch (error) {
|
||||
return { saved: false, error }
|
||||
}
|
||||
}, [appId, buildSnapshot, isCollaborationEnabled, storeApi, updateCachedContent, updateFileContent])
|
||||
}, [appId, buildSnapshot, createCollaborativeSaveWaiter, isCollaborationEnabled, storeApi, updateCachedContent, updateFileContent])
|
||||
|
||||
const saveFile = useCallback(async (
|
||||
fileId: string,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user